canopycms 0.0.6 → 0.0.8

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.
@@ -1,78 +1,2704 @@
1
- /**
2
- * CLI command: npx canopycms generate-ai-content
3
- *
4
- * Generates static AI-ready content files from CanopyCMS content.
5
- */
6
- import path from 'node:path';
7
- import { generateAIContentFiles } from '../build/generate-ai-content';
8
- import { getErrorMessage } from '../utils/error';
9
- export async function generateAIContentCLI(options) {
10
- const { projectDir, outputDir = 'public/ai', configPath, appDir = 'app' } = options;
11
- console.log('\nCanopyCMS generate-ai-content\n');
12
- // Load adopter's canopycms config
13
- const canopyConfigPath = path.join(projectDir, 'canopycms.config.ts');
14
- let canopyConfigModule;
15
- try {
16
- canopyConfigModule = (await import(canopyConfigPath));
17
- }
18
- catch (err) {
19
- console.error(`Could not load config from ${canopyConfigPath}`);
20
- console.error(getErrorMessage(err));
21
- process.exit(1);
22
- }
23
- // Extract the server config (defineCanopyConfig returns { server, client })
24
- const configExport = canopyConfigModule.default ?? canopyConfigModule.config ?? canopyConfigModule;
25
- const serverConfig = typeof configExport === 'object' && configExport !== null && 'server' in configExport
26
- ? configExport.server
27
- : configExport;
28
- // Load entry schema registry
29
- const schemasPath = path.join(projectDir, appDir, 'schemas.ts');
30
- let entrySchemaRegistry = {};
31
- try {
32
- const schemasModule = (await import(schemasPath));
33
- entrySchemaRegistry =
34
- schemasModule.entrySchemaRegistry ?? schemasModule;
35
- }
36
- catch {
37
- console.warn(` No ${appDir}/schemas.ts found, using empty entry schema registry`);
38
- }
39
- // Load AI config if specified
40
- let aiConfig;
41
- if (configPath) {
42
- try {
43
- const aiConfigModule = (await import(path.resolve(configPath)));
44
- aiConfig = aiConfigModule.aiContentConfig ?? aiConfigModule.default ?? aiConfigModule.config;
45
- }
46
- catch (err) {
47
- console.error(`Could not load AI config from ${configPath}`);
48
- console.error(getErrorMessage(err));
49
- process.exit(1);
50
- }
51
- }
52
- // Validate AI config shape if provided
53
- if (aiConfig !== undefined && (typeof aiConfig !== 'object' || aiConfig === null)) {
54
- console.error('Invalid AI content config: expected an object.');
55
- process.exit(1);
56
- }
57
- // Validate loaded config has required shape
58
- if (!serverConfig ||
59
- typeof serverConfig !== 'object' ||
60
- !('mode' in serverConfig) ||
61
- !('contentRoot' in serverConfig)) {
62
- console.error('Invalid CanopyCMS config: expected an object with mode and contentRoot properties.');
63
- console.error('Make sure canopycms.config.ts uses defineCanopyConfig().');
64
- process.exit(1);
65
- }
66
- const resolvedOutput = path.resolve(projectDir, outputDir);
67
- console.log(` Output: ${resolvedOutput}`);
68
- console.log(` Mode: ${serverConfig.mode ?? 'dev'}`);
69
- const result = await generateAIContentFiles({
70
- config: serverConfig,
71
- entrySchemaRegistry: entrySchemaRegistry,
72
- outputDir: resolvedOutput,
73
- aiConfig: aiConfig,
1
+ // dist/cli/generate-ai-content.js
2
+ import path11 from "node:path";
3
+
4
+ // dist/build/generate-ai-content.js
5
+ import fs8 from "node:fs/promises";
6
+ import path10 from "node:path";
7
+
8
+ // dist/content-store.js
9
+ import fs3 from "node:fs/promises";
10
+ import path5 from "node:path";
11
+ import matter from "gray-matter";
12
+
13
+ // dist/utils/atomic-write.js
14
+ import fs from "node:fs/promises";
15
+ import path from "node:path";
16
+ async function atomicWriteFile(filePath, content) {
17
+ const dir = path.dirname(filePath);
18
+ await fs.mkdir(dir, { recursive: true });
19
+ const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
20
+ await fs.writeFile(tempPath, content, "utf-8");
21
+ try {
22
+ await fs.rename(tempPath, filePath);
23
+ } catch (err) {
24
+ await fs.unlink(tempPath).catch(() => {
74
25
  });
75
- console.log(`\n Generated ${result.fileCount} files`);
76
- console.log(` Output: ${result.outputDir}\n`);
26
+ throw err;
27
+ }
77
28
  }
78
- //# sourceMappingURL=generate-ai-content.js.map
29
+
30
+ // dist/content-id-index.js
31
+ import fs2 from "node:fs/promises";
32
+ import path2 from "node:path";
33
+
34
+ // dist/id.js
35
+ import { generate } from "short-uuid";
36
+
37
+ // dist/paths/normalize.js
38
+ function normalizeFilesystemPath(path12) {
39
+ return path12.split(/[\\/]+/).filter(Boolean).join("/");
40
+ }
41
+ function hasTraversalSequence(path12) {
42
+ const normalized = normalizeFilesystemPath(path12);
43
+ return normalized.includes("..");
44
+ }
45
+ function createLogicalPath(...segments) {
46
+ const normalized = segments.map((s) => normalizeFilesystemPath(s)).filter(Boolean).join("/");
47
+ if (hasTraversalSequence(normalized)) {
48
+ throw new Error(`Invalid path: contains traversal sequence: ${normalized}`);
49
+ }
50
+ return normalized;
51
+ }
52
+
53
+ // dist/paths/validation.js
54
+ var BASE58_PATTERN = "[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]";
55
+ var CONTENT_ID_PATTERN = new RegExp(`^${BASE58_PATTERN}{12}$`);
56
+ var PHYSICAL_SEGMENT_PATTERN = new RegExp(`\\.${BASE58_PATTERN}{12}(?:\\.[a-z]+)?$`);
57
+ function isValidContentId(id) {
58
+ return CONTENT_ID_PATTERN.test(id);
59
+ }
60
+
61
+ // dist/id.js
62
+ function generateId() {
63
+ const full = generate();
64
+ return full.substring(0, 12);
65
+ }
66
+ var isValidId = isValidContentId;
67
+
68
+ // dist/utils/error.js
69
+ function getErrorMessage(err) {
70
+ if (err instanceof Error) {
71
+ return err.message;
72
+ }
73
+ if (typeof err === "string") {
74
+ return err;
75
+ }
76
+ return String(err);
77
+ }
78
+ function isNodeError(err) {
79
+ return err instanceof Error && "code" in err;
80
+ }
81
+ function isNotFoundError(err) {
82
+ return isNodeError(err) && err.code === "ENOENT";
83
+ }
84
+ function isFileExistsError(err) {
85
+ return isNodeError(err) && err.code === "EEXIST";
86
+ }
87
+
88
+ // dist/content-id-index.js
89
+ var EMPTY_LOGICAL_PATH = "";
90
+ function toLogicalCollectionPath(physicalPath) {
91
+ if (physicalPath === ".")
92
+ return EMPTY_LOGICAL_PATH;
93
+ return physicalPath.split("/").map((seg) => extractSlugFromFilename(seg)).join("/");
94
+ }
95
+ var ContentIdIndex = class {
96
+ constructor(root) {
97
+ this.idToLocation = /* @__PURE__ */ new Map();
98
+ this.pathToId = /* @__PURE__ */ new Map();
99
+ this.byCollection = /* @__PURE__ */ new Map();
100
+ this.root = path2.resolve(root);
101
+ }
102
+ /**
103
+ * Build index by scanning filenames recursively.
104
+ * Throws if duplicate IDs found (collision detection).
105
+ */
106
+ async buildFromFilenames(startPath = "") {
107
+ await this.scanDirectory(startPath);
108
+ }
109
+ async scanDirectory(relativePath) {
110
+ const absoluteDir = path2.join(this.root, relativePath);
111
+ try {
112
+ const entries = await fs2.readdir(absoluteDir, { withFileTypes: true });
113
+ for (const entry of entries) {
114
+ if (entry.name.startsWith(".") || entry.name === "_ids_") {
115
+ continue;
116
+ }
117
+ const fullRelativePath = path2.join(relativePath, entry.name);
118
+ const id = extractIdFromFilename(entry.name);
119
+ if (id) {
120
+ if (this.idToLocation.has(id)) {
121
+ const existing = this.idToLocation.get(id);
122
+ throw new Error(`ID collision detected: ${id}
123
+ File 1: ${existing.relativePath}
124
+ File 2: ${fullRelativePath}`);
125
+ }
126
+ const location = {
127
+ id,
128
+ // already ContentId from extractIdFromFilename
129
+ type: entry.isDirectory() ? "collection" : "entry",
130
+ relativePath: fullRelativePath
131
+ // filesystem path with embedded IDs
132
+ };
133
+ if (!entry.isDirectory()) {
134
+ const slug = extractSlugFromFilename(entry.name);
135
+ const physicalCollection = path2.dirname(fullRelativePath);
136
+ const collectionPath = toLogicalCollectionPath(physicalCollection);
137
+ location.slug = slug;
138
+ location.collection = collectionPath;
139
+ if (!this.byCollection.has(collectionPath)) {
140
+ this.byCollection.set(collectionPath, /* @__PURE__ */ new Set());
141
+ }
142
+ this.byCollection.get(collectionPath).add(id);
143
+ }
144
+ this.idToLocation.set(id, location);
145
+ this.pathToId.set(fullRelativePath, id);
146
+ }
147
+ if (entry.isDirectory()) {
148
+ await this.scanDirectory(fullRelativePath);
149
+ }
150
+ }
151
+ } catch (err) {
152
+ if (err.code !== "ENOENT") {
153
+ throw err;
154
+ }
155
+ }
156
+ }
157
+ /**
158
+ * Forward lookup: ID → location (O(1))
159
+ */
160
+ findById(id) {
161
+ return this.idToLocation.get(id) || null;
162
+ }
163
+ /**
164
+ * Reverse lookup: path → ID (O(1))
165
+ */
166
+ findByPath(relativePath) {
167
+ return this.pathToId.get(relativePath) || null;
168
+ }
169
+ /**
170
+ * Get all ID locations in the index.
171
+ * Useful for validation and checking references.
172
+ */
173
+ getAllLocations() {
174
+ return Array.from(this.idToLocation.values());
175
+ }
176
+ /**
177
+ * Get all entries in a collection by collection path.
178
+ *
179
+ * Performance: O(1) + O(m) where m is the number of entries in the collection.
180
+ *
181
+ * @param collectionPath - The collection path (e.g., "content/posts")
182
+ * @returns Array of IdLocation objects for entries in the collection
183
+ */
184
+ getEntriesInCollection(collectionPath) {
185
+ const idSet = this.byCollection.get(collectionPath);
186
+ if (!idSet) {
187
+ return [];
188
+ }
189
+ const locations = [];
190
+ for (const id of idSet) {
191
+ const location = this.idToLocation.get(id);
192
+ if (location) {
193
+ locations.push(location);
194
+ }
195
+ }
196
+ return locations;
197
+ }
198
+ /**
199
+ * Add a new entry or collection to the index.
200
+ * Note: This only updates the in-memory index. The file with embedded ID
201
+ * must already exist on disk (created by ContentStore).
202
+ */
203
+ add(location) {
204
+ const id = extractIdFromFilename(path2.basename(location.relativePath));
205
+ if (!id) {
206
+ throw new Error(`Cannot add location without ID in filename: ${location.relativePath}`);
207
+ }
208
+ if (this.idToLocation.has(id)) {
209
+ const existing = this.idToLocation.get(id);
210
+ throw new Error(`ID collision detected: ${id}
211
+ File 1: ${existing.relativePath}
212
+ File 2: ${location.relativePath}`);
213
+ }
214
+ const fullLocation = {
215
+ ...location,
216
+ id
217
+ // already ContentId from extractIdFromFilename
218
+ };
219
+ this.idToLocation.set(id, fullLocation);
220
+ this.pathToId.set(location.relativePath, id);
221
+ if (fullLocation.type === "entry" && fullLocation.collection) {
222
+ if (!this.byCollection.has(fullLocation.collection)) {
223
+ this.byCollection.set(fullLocation.collection, /* @__PURE__ */ new Set());
224
+ }
225
+ this.byCollection.get(fullLocation.collection).add(id);
226
+ }
227
+ }
228
+ /**
229
+ * Remove an entry or collection from the index by ID.
230
+ * Note: This only updates the in-memory index. The file must be deleted separately.
231
+ */
232
+ remove(id) {
233
+ const location = this.idToLocation.get(id);
234
+ if (!location)
235
+ return;
236
+ if (location.type === "entry" && location.collection) {
237
+ const idSet = this.byCollection.get(location.collection);
238
+ if (idSet) {
239
+ idSet.delete(id);
240
+ if (idSet.size === 0) {
241
+ this.byCollection.delete(location.collection);
242
+ }
243
+ }
244
+ }
245
+ this.idToLocation.delete(id);
246
+ this.pathToId.delete(location.relativePath);
247
+ }
248
+ /**
249
+ * Update the path for an existing ID (e.g., after file rename/move).
250
+ * This is used to keep the index in sync when files are renamed.
251
+ */
252
+ updatePath(id, newRelativePath) {
253
+ const location = this.idToLocation.get(id);
254
+ if (!location) {
255
+ throw new Error(`Cannot update path for unknown ID: ${id}`);
256
+ }
257
+ this.pathToId.delete(location.relativePath);
258
+ location.relativePath = newRelativePath;
259
+ if (location.type === "entry") {
260
+ const oldCollection = location.collection;
261
+ location.slug = extractSlugFromFilename(path2.basename(newRelativePath));
262
+ const physicalCollection = path2.dirname(newRelativePath);
263
+ location.collection = toLogicalCollectionPath(physicalCollection);
264
+ if (oldCollection !== location.collection) {
265
+ if (oldCollection) {
266
+ const oldSet = this.byCollection.get(oldCollection);
267
+ if (oldSet) {
268
+ oldSet.delete(id);
269
+ if (oldSet.size === 0) {
270
+ this.byCollection.delete(oldCollection);
271
+ }
272
+ }
273
+ }
274
+ if (location.collection) {
275
+ if (!this.byCollection.has(location.collection)) {
276
+ this.byCollection.set(location.collection, /* @__PURE__ */ new Set());
277
+ }
278
+ this.byCollection.get(location.collection).add(id);
279
+ }
280
+ }
281
+ }
282
+ this.pathToId.set(newRelativePath, id);
283
+ }
284
+ };
285
+ function extractIdFromFilename(filename) {
286
+ if (filename.startsWith(".")) {
287
+ return null;
288
+ }
289
+ const parts = filename.split(".");
290
+ if (parts.length >= 3) {
291
+ const candidate = parts[parts.length - 2];
292
+ if (isValidId(candidate))
293
+ return candidate;
294
+ }
295
+ if (parts.length === 2) {
296
+ const candidate = parts[parts.length - 1];
297
+ if (isValidId(candidate))
298
+ return candidate;
299
+ }
300
+ return null;
301
+ }
302
+ async function resolveCollectionPath(root, logicalPath) {
303
+ const fs9 = await import("node:fs/promises");
304
+ const path12 = await import("node:path");
305
+ const segments = logicalPath.split("/").filter(Boolean);
306
+ let currentPath = root;
307
+ for (const segment of segments) {
308
+ try {
309
+ const entries = await fs9.readdir(currentPath, { withFileTypes: true });
310
+ const matchingDir = entries.find((entry) => {
311
+ if (!entry.isDirectory())
312
+ return false;
313
+ const logicalName = extractSlugFromFilename(entry.name);
314
+ return logicalName === segment;
315
+ });
316
+ if (matchingDir) {
317
+ currentPath = path12.join(currentPath, matchingDir.name);
318
+ } else {
319
+ return null;
320
+ }
321
+ } catch (err) {
322
+ if (isNotFoundError(err))
323
+ return null;
324
+ throw err;
325
+ }
326
+ }
327
+ return currentPath;
328
+ }
329
+ function extractEntryTypeFromFilename(filename) {
330
+ if (filename.startsWith("."))
331
+ return null;
332
+ const parts = filename.split(".");
333
+ if (parts.length >= 4) {
334
+ const possibleId = parts[parts.length - 2];
335
+ if (isValidId(possibleId)) {
336
+ return parts[0];
337
+ }
338
+ }
339
+ return null;
340
+ }
341
+ function extractSlugFromFilename(filename, entryTypeName) {
342
+ const parts = filename.split(".");
343
+ if (parts.length >= 3) {
344
+ const possibleId = parts[parts.length - 2];
345
+ if (isValidId(possibleId)) {
346
+ let slugParts = parts.slice(0, parts.length - 2);
347
+ if (entryTypeName && slugParts.length > 1 && slugParts[0] === entryTypeName) {
348
+ slugParts = slugParts.slice(1);
349
+ } else if (parts.length >= 4 && slugParts.length > 1) {
350
+ slugParts = slugParts.slice(1);
351
+ }
352
+ return slugParts.join(".");
353
+ }
354
+ }
355
+ if (parts.length === 2) {
356
+ const possibleId = parts[parts.length - 1];
357
+ if (isValidId(possibleId)) {
358
+ return parts[0];
359
+ }
360
+ }
361
+ if (parts.length > 1) {
362
+ return parts.slice(0, -1).join(".");
363
+ }
364
+ return filename;
365
+ }
366
+
367
+ // dist/utils/format.js
368
+ var getFormatExtension = (format) => {
369
+ if (format === "md")
370
+ return ".md";
371
+ if (format === "mdx")
372
+ return ".mdx";
373
+ return ".json";
374
+ };
375
+
376
+ // dist/paths/branch.js
377
+ import path4 from "node:path";
378
+
379
+ // dist/operating-mode/client-safe-strategy.js
380
+ var ProdClientSafeStrategy = class {
381
+ constructor() {
382
+ this.mode = "prod";
383
+ }
384
+ // UI Feature Flags
385
+ supportsBranching() {
386
+ return true;
387
+ }
388
+ supportsStatusBadge() {
389
+ return true;
390
+ }
391
+ supportsComments() {
392
+ return true;
393
+ }
394
+ supportsPullRequests() {
395
+ return true;
396
+ }
397
+ // Simple Data
398
+ getPermissionsFileName() {
399
+ return "permissions.json";
400
+ }
401
+ getGroupsFileName() {
402
+ return "groups.json";
403
+ }
404
+ shouldCommit() {
405
+ return true;
406
+ }
407
+ shouldPush() {
408
+ return true;
409
+ }
410
+ };
411
+ var LocalProdSimClientSafeStrategy = class {
412
+ constructor() {
413
+ this.mode = "prod-sim";
414
+ }
415
+ // UI Feature Flags
416
+ supportsBranching() {
417
+ return true;
418
+ }
419
+ supportsStatusBadge() {
420
+ return true;
421
+ }
422
+ supportsComments() {
423
+ return true;
424
+ }
425
+ supportsPullRequests() {
426
+ return false;
427
+ }
428
+ // Simple Data
429
+ getPermissionsFileName() {
430
+ return "permissions.json";
431
+ }
432
+ getGroupsFileName() {
433
+ return "groups.json";
434
+ }
435
+ shouldCommit() {
436
+ return true;
437
+ }
438
+ shouldPush() {
439
+ return true;
440
+ }
441
+ };
442
+ var LocalSimpleClientSafeStrategy = class {
443
+ constructor() {
444
+ this.mode = "dev";
445
+ }
446
+ // UI Feature Flags
447
+ supportsBranching() {
448
+ return false;
449
+ }
450
+ supportsStatusBadge() {
451
+ return false;
452
+ }
453
+ supportsComments() {
454
+ return false;
455
+ }
456
+ supportsPullRequests() {
457
+ return false;
458
+ }
459
+ // Simple Data
460
+ getPermissionsFileName() {
461
+ return "permissions.json";
462
+ }
463
+ getGroupsFileName() {
464
+ return "groups.json";
465
+ }
466
+ shouldCommit() {
467
+ return false;
468
+ }
469
+ shouldPush() {
470
+ return false;
471
+ }
472
+ };
473
+
474
+ // dist/operating-mode/client-unsafe-strategy.js
475
+ import path3 from "node:path";
476
+
477
+ // dist/config/types.js
478
+ var primitiveFieldTypes = [
479
+ "string",
480
+ "number",
481
+ "boolean",
482
+ "datetime",
483
+ "rich-text",
484
+ "markdown",
485
+ "mdx",
486
+ "image",
487
+ "code"
488
+ ];
489
+ var fieldTypes = [
490
+ ...primitiveFieldTypes,
491
+ "select",
492
+ "reference",
493
+ "object",
494
+ "block"
495
+ ];
496
+
497
+ // dist/config/schemas/config.js
498
+ import { z as z4 } from "zod";
499
+
500
+ // dist/config/schemas/collection.js
501
+ import { z as z2 } from "zod";
502
+ import { isAbsolute } from "pathe";
503
+
504
+ // dist/config/schemas/field.js
505
+ import { z } from "zod";
506
+ var fieldBaseSchema = z.object({
507
+ name: z.string().min(1),
508
+ label: z.string().optional(),
509
+ description: z.string().optional(),
510
+ required: z.boolean().optional(),
511
+ list: z.boolean().optional()
512
+ });
513
+ var selectOptionSchema = z.union([
514
+ z.string(),
515
+ z.object({
516
+ label: z.string().min(1),
517
+ value: z.string().min(1)
518
+ })
519
+ ]);
520
+ var referenceOptionSchema = z.union([
521
+ z.string(),
522
+ z.object({
523
+ label: z.string().min(1),
524
+ value: z.string().min(1)
525
+ })
526
+ ]);
527
+ var primitiveFieldSchema = fieldBaseSchema.extend({
528
+ type: z.enum(primitiveFieldTypes)
529
+ });
530
+ var selectFieldSchema = fieldBaseSchema.extend({
531
+ type: z.literal("select"),
532
+ options: z.array(selectOptionSchema).min(1)
533
+ });
534
+ var referenceFieldSchema = fieldBaseSchema.extend({
535
+ type: z.literal("reference"),
536
+ collections: z.array(z.string().min(1)).min(1),
537
+ displayField: z.string().min(1).optional(),
538
+ options: z.array(referenceOptionSchema).optional()
539
+ });
540
+ var fieldHolder = [z.never()];
541
+ var blockSchema = z.object({
542
+ name: z.string().min(1),
543
+ label: z.string().optional(),
544
+ description: z.string().optional(),
545
+ fields: z.array(z.lazy(() => fieldHolder[0])).min(1)
546
+ });
547
+ var blockFieldSchema = fieldBaseSchema.extend({
548
+ type: z.literal("block"),
549
+ templates: z.array(blockSchema).min(1)
550
+ });
551
+ var objectFieldSchema = fieldBaseSchema.extend({
552
+ type: z.literal("object"),
553
+ fields: z.array(z.lazy(() => fieldHolder[0])).min(1)
554
+ });
555
+ var customFieldSchema = z.lazy(() => fieldBaseSchema.extend({
556
+ type: z.string().min(1).refine((val) => !fieldTypes.includes(val), {
557
+ message: "Custom field types must not conflict with built-in types"
558
+ })
559
+ }).passthrough());
560
+ var knownFieldSchema = z.discriminatedUnion("type", [
561
+ primitiveFieldSchema,
562
+ selectFieldSchema,
563
+ referenceFieldSchema,
564
+ objectFieldSchema,
565
+ blockFieldSchema
566
+ ]);
567
+ var fieldSchema = z.lazy(() => z.union([knownFieldSchema, customFieldSchema]));
568
+ fieldHolder[0] = fieldSchema;
569
+
570
+ // dist/config/schemas/collection.js
571
+ var relativePathSchema = z2.string().min(1).refine((val) => !isAbsolute(val), { message: "Path must be relative" }).refine((val) => !val.split(/[\\/]+/).includes(".."), {
572
+ message: 'Path must not contain ".."'
573
+ }).transform((val) => val.split(/[\\/]+/).filter(Boolean).join("/"));
574
+ var entryTypeSchema = z2.object({
575
+ name: z2.string().min(1),
576
+ format: z2.enum(["md", "mdx", "json"]),
577
+ schema: z2.array(z2.lazy(() => fieldSchema)).min(1),
578
+ label: z2.string().optional(),
579
+ description: z2.string().optional(),
580
+ default: z2.boolean().optional(),
581
+ maxItems: z2.number().int().positive().optional()
582
+ });
583
+ var collectionSchema = z2.lazy(() => z2.object({
584
+ name: z2.string().min(1),
585
+ path: relativePathSchema,
586
+ label: z2.string().optional(),
587
+ description: z2.string().optional(),
588
+ entries: z2.array(entryTypeSchema).optional(),
589
+ collections: z2.array(collectionSchema).optional(),
590
+ order: z2.array(z2.string()).optional()
591
+ // Embedded IDs for ordering items
592
+ }).refine((data) => data.entries || data.collections, {
593
+ message: "Collection must have entries or collections"
594
+ }));
595
+ var rootCollectionSchema = z2.object({
596
+ entries: z2.array(entryTypeSchema).optional(),
597
+ collections: z2.array(collectionSchema).optional(),
598
+ order: z2.array(z2.string()).optional()
599
+ // Embedded IDs for ordering items
600
+ });
601
+
602
+ // dist/config/schemas/media.js
603
+ import { z as z3 } from "zod";
604
+ var mediaSchema = z3.union([
605
+ z3.object({
606
+ adapter: z3.literal("local"),
607
+ publicBaseUrl: z3.string().url().optional()
608
+ }),
609
+ z3.object({
610
+ adapter: z3.literal("s3"),
611
+ bucket: z3.string().min(1),
612
+ region: z3.string().min(1),
613
+ publicBaseUrl: z3.string().url().optional()
614
+ }),
615
+ z3.object({
616
+ adapter: z3.literal("lfs"),
617
+ publicBaseUrl: z3.string().url().optional()
618
+ }),
619
+ z3.object({
620
+ adapter: z3.string().min(1),
621
+ publicBaseUrl: z3.string().url().optional()
622
+ })
623
+ ]);
624
+
625
+ // dist/config/schemas/config.js
626
+ var defaultBranchAccessSchema = z4.enum(["allow", "deny"]).default("deny");
627
+ var defaultPathAccessSchema = z4.enum(["allow", "deny"]).default("deny");
628
+ var defaultBaseBranchSchema = z4.string().default("main");
629
+ var defaultRemoteNameSchema = z4.string().default("origin");
630
+ var defaultRemoteUrlSchema = z4.string().min(1);
631
+ var gitBotAuthorNameSchema = z4.string().min(1);
632
+ var gitBotAuthorEmailSchema = z4.string().email();
633
+ var githubTokenEnvVarSchema = z4.string().default("GITHUB_BOT_TOKEN");
634
+ var operatingModeSchema = z4.enum(["prod", "prod-sim", "dev"]).default("dev");
635
+ var deployedAsSchema = z4.enum(["static", "server"]).default("server");
636
+ var contentRootSchema = relativePathSchema.default("content");
637
+ var sourceRootSchema = z4.string().min(1).optional();
638
+ var deploymentNameSchema = z4.string().default("prod");
639
+ var editorConfigSchema = z4.object({
640
+ title: z4.string().optional(),
641
+ subtitle: z4.string().optional(),
642
+ theme: z4.unknown().optional(),
643
+ previewBase: z4.record(z4.string()).optional(),
644
+ // UI handler functions (runtime only, don't serialize)
645
+ onAccountClick: z4.function().returns(z4.void()).optional(),
646
+ onLogoutClick: z4.function().returns(z4.void()).optional(),
647
+ // Optional: custom account component (e.g., Clerk's UserButton)
648
+ AccountComponent: z4.custom().optional()
649
+ });
650
+ var CanopyConfigSchema = z4.object({
651
+ schema: rootCollectionSchema.optional(),
652
+ media: mediaSchema.optional(),
653
+ defaultBranchAccess: defaultBranchAccessSchema.optional(),
654
+ defaultPathAccess: defaultPathAccessSchema.optional(),
655
+ defaultBaseBranch: defaultBaseBranchSchema.optional(),
656
+ defaultRemoteName: defaultRemoteNameSchema.optional(),
657
+ defaultRemoteUrl: defaultRemoteUrlSchema.optional(),
658
+ gitBotAuthorName: gitBotAuthorNameSchema,
659
+ gitBotAuthorEmail: gitBotAuthorEmailSchema,
660
+ githubTokenEnvVar: githubTokenEnvVarSchema.optional(),
661
+ mode: operatingModeSchema,
662
+ // Has .default(), so not optional in output type
663
+ deployedAs: deployedAsSchema,
664
+ // Has .default('server'), so always present after validation
665
+ settingsBranch: z4.string().optional(),
666
+ autoCreateSettingsPR: z4.boolean().optional(),
667
+ deploymentName: deploymentNameSchema.optional(),
668
+ contentRoot: contentRootSchema.default("content"),
669
+ sourceRoot: sourceRootSchema.optional(),
670
+ editor: editorConfigSchema.optional(),
671
+ authPlugin: z4.custom().optional()
672
+ });
673
+ var DEFAULT_PROD_WORKSPACE = "/mnt/efs/workspace";
674
+
675
+ // dist/config/schemas/permissions.js
676
+ import { z as z5 } from "zod";
677
+ var permissionTargetSchema = z5.object({
678
+ allowedUsers: z5.array(z5.string()).optional(),
679
+ allowedGroups: z5.array(z5.string()).optional()
680
+ });
681
+ var pathPermissionSchema = z5.object({
682
+ path: z5.string().min(1),
683
+ read: permissionTargetSchema.optional(),
684
+ edit: permissionTargetSchema.optional(),
685
+ review: permissionTargetSchema.optional()
686
+ });
687
+
688
+ // dist/config/flatten.js
689
+ import { join, normalize } from "pathe";
690
+
691
+ // dist/paths/types.js
692
+ var ROOT_COLLECTION_ID = "__rootcoll__";
693
+
694
+ // dist/config/flatten.js
695
+ var normalizePathValue = (val) => normalize(val).split("/").filter(Boolean).join("/");
696
+ var flattenSchema = (root, basePath = "") => {
697
+ const flat = [];
698
+ const base = normalizePathValue(basePath || "");
699
+ const walkCollection = (collection, parentPath) => {
700
+ const normalizedPath = normalizePathValue(collection.path);
701
+ let logicalPath;
702
+ if (parentPath && parentPath !== base) {
703
+ logicalPath = join(parentPath, collection.name);
704
+ } else if (parentPath === base) {
705
+ logicalPath = join(base, normalizedPath);
706
+ } else {
707
+ logicalPath = normalizedPath;
708
+ }
709
+ const normalizedFull = normalizePathValue(logicalPath);
710
+ flat.push({
711
+ type: "collection",
712
+ logicalPath: createLogicalPath(normalizedFull),
713
+ name: collection.name,
714
+ label: collection.label,
715
+ description: collection.description,
716
+ contentId: collection.contentId,
717
+ parentPath: parentPath ? createLogicalPath(parentPath) : void 0,
718
+ entries: collection.entries,
719
+ collections: collection.collections,
720
+ order: collection.order
721
+ });
722
+ if (collection.entries) {
723
+ for (const entryType of collection.entries) {
724
+ const entryTypePath = join(normalizedFull, entryType.name);
725
+ flat.push({
726
+ type: "entry-type",
727
+ logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
728
+ name: entryType.name,
729
+ label: entryType.label,
730
+ description: entryType.description,
731
+ parentPath: createLogicalPath(normalizedFull),
732
+ format: entryType.format,
733
+ schema: entryType.schema,
734
+ schemaRef: entryType.schemaRef,
735
+ default: entryType.default,
736
+ maxItems: entryType.maxItems
737
+ });
738
+ }
739
+ }
740
+ if (collection.collections) {
741
+ for (const child of collection.collections) {
742
+ walkCollection(child, normalizedFull);
743
+ }
744
+ }
745
+ };
746
+ if (base) {
747
+ flat.push({
748
+ type: "collection",
749
+ logicalPath: createLogicalPath(base),
750
+ name: base,
751
+ // Use base path as the name (e.g., 'content')
752
+ label: void 0,
753
+ // Root collection has no label
754
+ contentId: ROOT_COLLECTION_ID,
755
+ // Sentinel — root dir has no embedded ID
756
+ parentPath: void 0,
757
+ // No parent - this is the root
758
+ entries: root.entries,
759
+ collections: root.collections,
760
+ order: root.order
761
+ });
762
+ }
763
+ if (root.entries) {
764
+ for (const entryType of root.entries) {
765
+ const entryTypePath = base ? join(base, entryType.name) : entryType.name;
766
+ flat.push({
767
+ type: "entry-type",
768
+ logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
769
+ name: entryType.name,
770
+ label: entryType.label,
771
+ description: entryType.description,
772
+ parentPath: base ? createLogicalPath(base) : createLogicalPath(""),
773
+ // Now references the root collection (e.g., 'content')
774
+ format: entryType.format,
775
+ schema: entryType.schema,
776
+ schemaRef: entryType.schemaRef,
777
+ default: entryType.default,
778
+ maxItems: entryType.maxItems
779
+ });
780
+ }
781
+ }
782
+ if (root.collections) {
783
+ for (const collection of root.collections) {
784
+ walkCollection(collection, base || "");
785
+ }
786
+ }
787
+ return flat;
788
+ };
789
+
790
+ // dist/operating-mode/client-unsafe-strategy.js
791
+ var ProdStrategy = class extends ProdClientSafeStrategy {
792
+ // All client-safe methods inherited automatically from ProdClientSafeStrategy:
793
+ // - mode, supportsBranching(), supportsStatusBadge(), supportsComments()
794
+ // - supportsPullRequests(), getPermissionsFileName(), getGroupsFileName()
795
+ // - shouldCommit(), shouldPush()
796
+ // Add client-unsafe methods (use Node.js APIs)
797
+ getWorkspaceRoot(_sourceRoot) {
798
+ return path3.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE);
799
+ }
800
+ getContentRoot(sourceRoot) {
801
+ return path3.resolve(sourceRoot ?? process.cwd(), "content");
802
+ }
803
+ getContentBranchesRoot(sourceRoot) {
804
+ return path3.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
805
+ }
806
+ getContentBranchRoot(branchName, sourceRoot) {
807
+ return path3.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
808
+ }
809
+ getGitExcludePattern() {
810
+ return ".canopy-meta/";
811
+ }
812
+ getPermissionsFilePath(root) {
813
+ return path3.join(root, this.getPermissionsFileName());
814
+ }
815
+ getGroupsFilePath(root) {
816
+ return path3.join(root, this.getGroupsFileName());
817
+ }
818
+ getRemoteUrlConfig() {
819
+ return {
820
+ shouldAutoInitLocal: false,
821
+ defaultRemotePath: "",
822
+ envVarName: "CANOPYCMS_REMOTE_URL",
823
+ autoDetectRemotePath: path3.join(this.getWorkspaceRoot(), "remote.git")
824
+ };
825
+ }
826
+ requiresExistingRepo() {
827
+ return false;
828
+ }
829
+ getSettingsBranchName(config) {
830
+ if (config.settingsBranch)
831
+ return config.settingsBranch;
832
+ const deploymentName = config.deploymentName ?? "prod";
833
+ return `canopycms-settings-${deploymentName}`;
834
+ }
835
+ getSettingsRoot(sourceRoot) {
836
+ return path3.join(this.getWorkspaceRoot(sourceRoot), "settings");
837
+ }
838
+ usesSeparateSettingsBranch() {
839
+ return true;
840
+ }
841
+ validateConfig(config) {
842
+ if (!config.gitBotAuthorName || !config.gitBotAuthorEmail) {
843
+ throw new Error("gitBotAuthorName and gitBotAuthorEmail are required in prod mode");
844
+ }
845
+ }
846
+ shouldCreateSettingsPR(config) {
847
+ return config.autoCreateSettingsPR ?? true;
848
+ }
849
+ };
850
+ var LocalProdSimStrategy = class extends LocalProdSimClientSafeStrategy {
851
+ // Inherits client-safe methods from LocalProdSimClientSafeStrategy
852
+ getWorkspaceRoot(sourceRoot) {
853
+ return path3.resolve(sourceRoot ?? process.cwd(), ".canopy-prod-sim");
854
+ }
855
+ getContentRoot(sourceRoot) {
856
+ return path3.resolve(sourceRoot ?? process.cwd(), "content");
857
+ }
858
+ getContentBranchesRoot(sourceRoot) {
859
+ return path3.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
860
+ }
861
+ getContentBranchRoot(branchName, sourceRoot) {
862
+ return path3.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
863
+ }
864
+ getGitExcludePattern() {
865
+ return ".canopy-meta/";
866
+ }
867
+ getPermissionsFilePath(root) {
868
+ return path3.join(root, this.getPermissionsFileName());
869
+ }
870
+ getGroupsFilePath(root) {
871
+ return path3.join(root, this.getGroupsFileName());
872
+ }
873
+ getRemoteUrlConfig() {
874
+ return {
875
+ shouldAutoInitLocal: true,
876
+ defaultRemotePath: ".canopy-prod-sim/remote.git",
877
+ envVarName: "CANOPYCMS_REMOTE_URL"
878
+ };
879
+ }
880
+ requiresExistingRepo() {
881
+ return false;
882
+ }
883
+ getSettingsBranchName(config) {
884
+ if (config.settingsBranch)
885
+ return config.settingsBranch;
886
+ const deploymentName = config.deploymentName ?? "prod";
887
+ return `canopycms-settings-${deploymentName}`;
888
+ }
889
+ getSettingsRoot(sourceRoot) {
890
+ return path3.join(this.getWorkspaceRoot(sourceRoot), "settings");
891
+ }
892
+ usesSeparateSettingsBranch() {
893
+ return true;
894
+ }
895
+ validateConfig(_config) {
896
+ }
897
+ shouldCreateSettingsPR(_config) {
898
+ return false;
899
+ }
900
+ };
901
+ var LocalSimpleStrategy = class extends LocalSimpleClientSafeStrategy {
902
+ // Inherits: supportsBranching() returns false, getPermissionsFileName() returns 'permissions.local.json'
903
+ getWorkspaceRoot(sourceRoot) {
904
+ return path3.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
905
+ }
906
+ getContentRoot(sourceRoot) {
907
+ return path3.resolve(sourceRoot ?? process.cwd(), "content");
908
+ }
909
+ getContentBranchesRoot(_sourceRoot) {
910
+ throw new Error("No branching in dev mode");
911
+ }
912
+ getContentBranchRoot(_branchName, _sourceRoot) {
913
+ throw new Error("No branching in dev mode");
914
+ }
915
+ getGitExcludePattern() {
916
+ return ".canopy-meta/";
917
+ }
918
+ getPermissionsFilePath(root) {
919
+ return path3.join(this.getWorkspaceRoot(root), "settings", "permissions.json");
920
+ }
921
+ getGroupsFilePath(root) {
922
+ return path3.join(this.getWorkspaceRoot(root), "settings", "groups.json");
923
+ }
924
+ getRemoteUrlConfig() {
925
+ return {
926
+ shouldAutoInitLocal: false,
927
+ defaultRemotePath: "",
928
+ envVarName: "CANOPYCMS_REMOTE_URL"
929
+ };
930
+ }
931
+ requiresExistingRepo() {
932
+ return true;
933
+ }
934
+ getSettingsBranchName(config) {
935
+ return config.defaultBaseBranch ?? "main";
936
+ }
937
+ getSettingsRoot(sourceRoot) {
938
+ return path3.join(this.getWorkspaceRoot(sourceRoot), "settings");
939
+ }
940
+ usesSeparateSettingsBranch() {
941
+ return false;
942
+ }
943
+ validateConfig(_config) {
944
+ }
945
+ shouldCreateSettingsPR(_config) {
946
+ return false;
947
+ }
948
+ };
949
+ var strategyCache = /* @__PURE__ */ new Map();
950
+ function operatingStrategy(mode) {
951
+ const cached = strategyCache.get(mode);
952
+ if (cached)
953
+ return cached;
954
+ let strategy;
955
+ switch (mode) {
956
+ case "prod":
957
+ strategy = new ProdStrategy();
958
+ break;
959
+ case "prod-sim":
960
+ strategy = new LocalProdSimStrategy();
961
+ break;
962
+ case "dev":
963
+ strategy = new LocalSimpleStrategy();
964
+ break;
965
+ default: {
966
+ const _exhaustive = mode;
967
+ throw new Error(`Unknown operating mode: ${_exhaustive}`);
968
+ }
969
+ }
970
+ strategyCache.set(mode, strategy);
971
+ return strategy;
972
+ }
973
+
974
+ // dist/paths/branch.js
975
+ var BranchPathError = class extends Error {
976
+ };
977
+ function sanitizeBranchName(branchName) {
978
+ const replaced = branchName.replace(/[^a-zA-Z0-9._-]/g, "-");
979
+ const squashed = replaced.replace(/-+/g, "-");
980
+ const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
981
+ return trimmedDots || "branch";
982
+ }
983
+ var resolveContentBranchesRoot = (mode, override) => {
984
+ return operatingStrategy(mode).getContentBranchesRoot(override);
985
+ };
986
+ function resolveBranchPath(options) {
987
+ if (options.branchName.includes("..")) {
988
+ throw new BranchPathError("Branch name cannot contain traversal segments");
989
+ }
990
+ const safeBranch = sanitizeBranchName(options.branchName);
991
+ const strategy = operatingStrategy(options.mode);
992
+ const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
993
+ const normalizedBase = path4.resolve(baseRoot);
994
+ const baseWithSep = normalizedBase.endsWith(path4.sep) ? normalizedBase : `${normalizedBase}${path4.sep}`;
995
+ const branchRoot = strategy.getContentBranchRoot(safeBranch, options.basePathOverride);
996
+ const withinBase = (target) => {
997
+ const resolved = path4.resolve(target);
998
+ return resolved === normalizedBase || resolved.startsWith(baseWithSep);
999
+ };
1000
+ if (!withinBase(branchRoot)) {
1001
+ throw new BranchPathError("Branch path resolves outside the base root");
1002
+ }
1003
+ return { branchRoot, baseRoot: normalizedBase, branchName: safeBranch };
1004
+ }
1005
+
1006
+ // dist/content-store.js
1007
+ var ContentStoreError = class extends Error {
1008
+ };
1009
+ function getDefaultEntryType(entries) {
1010
+ if (!entries || entries.length === 0)
1011
+ return void 0;
1012
+ return entries.find((e) => e.default) || entries[0];
1013
+ }
1014
+ function validateSlug(slug) {
1015
+ if (slug.includes("/")) {
1016
+ throw new ContentStoreError("Slugs cannot contain forward slashes. Use nested collections instead.");
1017
+ }
1018
+ if (slug.includes("\\")) {
1019
+ throw new ContentStoreError("Slugs cannot contain backslashes. Use nested collections instead.");
1020
+ }
1021
+ }
1022
+ var ContentStore = class {
1023
+ constructor(root, flatSchema) {
1024
+ this.indexLoaded = false;
1025
+ this.root = path5.resolve(root);
1026
+ this.schemaIndex = new Map(flatSchema.map((item) => [item.logicalPath, item]));
1027
+ this._idIndex = new ContentIdIndex(this.root);
1028
+ }
1029
+ /**
1030
+ * Get the ID index, ensuring it's loaded first.
1031
+ * This getter automatically loads the index on first access.
1032
+ */
1033
+ async idIndex() {
1034
+ if (!this.indexLoaded) {
1035
+ await this._idIndex.buildFromFilenames("content");
1036
+ this.indexLoaded = true;
1037
+ }
1038
+ return this._idIndex;
1039
+ }
1040
+ /**
1041
+ * Get all schema items for iteration.
1042
+ * Used internally by ReferenceResolver for path matching.
1043
+ */
1044
+ getSchemaItems() {
1045
+ return this.schemaIndex.values();
1046
+ }
1047
+ assertSchemaItem(path12) {
1048
+ const normalized = normalizeFilesystemPath(path12);
1049
+ const item = this.schemaIndex.get(normalized);
1050
+ if (!item) {
1051
+ throw new ContentStoreError(`Unknown schema item: ${path12}`);
1052
+ }
1053
+ return item;
1054
+ }
1055
+ assertCollection(collectionPath) {
1056
+ const item = this.assertSchemaItem(collectionPath);
1057
+ if (item.type !== "collection") {
1058
+ throw new ContentStoreError(`Path is not a collection: ${collectionPath}`);
1059
+ }
1060
+ return item;
1061
+ }
1062
+ /**
1063
+ * Build absolute and relative paths with security validation.
1064
+ * All entries use the unified filename pattern: {type}.{slug}.{id}.{ext}
1065
+ *
1066
+ * SECURITY BOUNDARY: This method prevents path traversal attacks by:
1067
+ * 1. Validating that resolved paths stay within the content root
1068
+ * 2. Checking slugs for malicious patterns (via validateSlug)
1069
+ * 3. Using path.resolve to normalize paths before validation
1070
+ *
1071
+ * This validation is performed BEFORE file I/O in resolveDocumentPath(),
1072
+ * ensuring permission checks happen before any file system access.
1073
+ *
1074
+ * @param options.existingId - Optional ID to use (for edits). If not provided, generates new ID.
1075
+ * @param options.entryTypeName - For collections with multiple entry types, specify which one to use. Defaults to the default entry type.
1076
+ */
1077
+ async buildPaths(schemaItem, slug, options = {}) {
1078
+ const rootWithSep = this.root.endsWith(path5.sep) ? this.root : `${this.root}${path5.sep}`;
1079
+ if (schemaItem.type === "entry-type") {
1080
+ const parentPath = schemaItem.parentPath || "";
1081
+ const parentCollection = this.schemaIndex.get(parentPath);
1082
+ if (!parentCollection || parentCollection.type !== "collection") {
1083
+ throw new ContentStoreError(`Parent collection not found for entry type: ${schemaItem.name}`);
1084
+ }
1085
+ const effectiveSlug = slug || schemaItem.name;
1086
+ return this.buildPaths(parentCollection, effectiveSlug, {
1087
+ ...options,
1088
+ entryTypeName: schemaItem.name
1089
+ });
1090
+ }
1091
+ if (schemaItem.type === "collection") {
1092
+ const safeSlug = slug.replace(/^\/+/, "");
1093
+ if (!safeSlug) {
1094
+ throw new ContentStoreError("Slug is required for collection entries");
1095
+ }
1096
+ validateSlug(safeSlug);
1097
+ let entryTypeConfig;
1098
+ if (options.entryTypeName) {
1099
+ entryTypeConfig = schemaItem.entries?.find((e) => e.name === options.entryTypeName);
1100
+ if (!entryTypeConfig) {
1101
+ throw new ContentStoreError(`Entry type '${options.entryTypeName}' not found in collection`);
1102
+ }
1103
+ } else {
1104
+ entryTypeConfig = getDefaultEntryType(schemaItem.entries);
1105
+ }
1106
+ const format = entryTypeConfig?.format || "json";
1107
+ const ext = getFormatExtension(format);
1108
+ const entryTypeName = entryTypeConfig?.name || "entry";
1109
+ let collectionRoot = await resolveCollectionPath(this.root, schemaItem.logicalPath);
1110
+ if (!collectionRoot) {
1111
+ collectionRoot = path5.resolve(this.root, schemaItem.logicalPath);
1112
+ }
1113
+ if (!collectionRoot.startsWith(rootWithSep)) {
1114
+ throw new ContentStoreError("Path traversal detected");
1115
+ }
1116
+ let id = options.existingId;
1117
+ let existingFilename;
1118
+ let existingEntryType;
1119
+ if (!id) {
1120
+ const entries = await fs3.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
1121
+ const existingFile = entries.find((entry) => {
1122
+ if (entry.isDirectory())
1123
+ return false;
1124
+ const fileEntryType = extractEntryTypeFromFilename(entry.name);
1125
+ const existingSlug = extractSlugFromFilename(entry.name, fileEntryType || void 0);
1126
+ return existingSlug === safeSlug;
1127
+ });
1128
+ if (existingFile) {
1129
+ id = extractIdFromFilename(existingFile.name) || void 0;
1130
+ existingFilename = existingFile.name;
1131
+ existingEntryType = extractEntryTypeFromFilename(existingFile.name) || void 0;
1132
+ }
1133
+ }
1134
+ const finalEntryTypeName = existingEntryType || entryTypeName;
1135
+ let filename;
1136
+ if (existingFilename && !id) {
1137
+ filename = existingFilename;
1138
+ } else {
1139
+ if (!id) {
1140
+ id = generateId();
1141
+ }
1142
+ filename = `${finalEntryTypeName}.${safeSlug}.${id}${ext}`;
1143
+ }
1144
+ const resolved = path5.resolve(collectionRoot, filename);
1145
+ const collectionRootWithSep = collectionRoot.endsWith(path5.sep) ? collectionRoot : `${collectionRoot}${path5.sep}`;
1146
+ if (!resolved.startsWith(collectionRootWithSep)) {
1147
+ throw new ContentStoreError("Path traversal detected");
1148
+ }
1149
+ return {
1150
+ absolutePath: resolved,
1151
+ relativePath: path5.relative(this.root, resolved),
1152
+ id
1153
+ };
1154
+ }
1155
+ throw new ContentStoreError("Invalid schema item type");
1156
+ }
1157
+ /**
1158
+ * Path resolution: resolves a URL path to a schema item
1159
+ * - Try as collection + slug (last segment = slug)
1160
+ */
1161
+ resolvePath(pathSegments) {
1162
+ if (pathSegments.length === 0) {
1163
+ throw new ContentStoreError("Empty path");
1164
+ }
1165
+ const logicalPath = pathSegments.join("/");
1166
+ const slug = pathSegments[pathSegments.length - 1];
1167
+ const collectionPath = pathSegments.slice(0, -1).join("/");
1168
+ const normalizedCollection = normalizeFilesystemPath(collectionPath);
1169
+ const collection = this.schemaIndex.get(normalizedCollection);
1170
+ if (collection?.type === "collection" && collection.entries) {
1171
+ return {
1172
+ schemaItem: collection,
1173
+ slug
1174
+ };
1175
+ }
1176
+ throw new ContentStoreError(`No schema item found for path: ${logicalPath}`);
1177
+ }
1178
+ async resolveDocumentPath(schemaPath, slug = "") {
1179
+ const schemaItem = this.assertSchemaItem(schemaPath);
1180
+ return await this.buildPaths(schemaItem, slug);
1181
+ }
1182
+ async read(collectionPath, slug = "", options = {}) {
1183
+ const schemaItem = this.assertSchemaItem(collectionPath);
1184
+ const { absolutePath, relativePath } = await this.buildPaths(schemaItem, slug);
1185
+ const raw = await fs3.readFile(absolutePath, "utf8");
1186
+ let doc;
1187
+ let format;
1188
+ let fields;
1189
+ if (schemaItem.type === "entry-type") {
1190
+ format = schemaItem.format;
1191
+ fields = schemaItem.schema;
1192
+ } else {
1193
+ const defaultEntry = getDefaultEntryType(schemaItem.entries);
1194
+ format = defaultEntry?.format || "json";
1195
+ fields = defaultEntry?.schema || [];
1196
+ }
1197
+ if (format === "json") {
1198
+ const data = JSON.parse(raw);
1199
+ doc = {
1200
+ collection: schemaItem.logicalPath,
1201
+ collectionName: schemaItem.name,
1202
+ format: "json",
1203
+ data,
1204
+ relativePath,
1205
+ absolutePath
1206
+ };
1207
+ } else {
1208
+ const parsed = matter(raw);
1209
+ doc = {
1210
+ collection: schemaItem.logicalPath,
1211
+ collectionName: schemaItem.name,
1212
+ format,
1213
+ data: parsed.data ?? {},
1214
+ body: parsed.content,
1215
+ relativePath,
1216
+ absolutePath
1217
+ };
1218
+ }
1219
+ if (options.resolveReferences !== false) {
1220
+ doc.data = await this.resolveReferencesInData(doc.data, fields);
1221
+ }
1222
+ return doc;
1223
+ }
1224
+ async write(collectionPath, slug = "", input, entryTypeName) {
1225
+ const idIndex = await this.idIndex();
1226
+ const schemaItem = this.assertSchemaItem(collectionPath);
1227
+ let expectedFormat;
1228
+ if (schemaItem.type === "entry-type") {
1229
+ expectedFormat = schemaItem.format;
1230
+ } else {
1231
+ let entryTypeConfig;
1232
+ if (entryTypeName) {
1233
+ entryTypeConfig = schemaItem.entries?.find((e) => e.name === entryTypeName);
1234
+ if (!entryTypeConfig) {
1235
+ throw new ContentStoreError(`Entry type '${entryTypeName}' not found in collection`);
1236
+ }
1237
+ } else {
1238
+ entryTypeConfig = getDefaultEntryType(schemaItem.entries);
1239
+ }
1240
+ expectedFormat = entryTypeConfig?.format || "json";
1241
+ }
1242
+ if (expectedFormat !== input.format) {
1243
+ throw new ContentStoreError(`Format mismatch: expects ${expectedFormat}, got ${input.format}`);
1244
+ }
1245
+ const { absolutePath, relativePath, id } = await this.buildPaths(schemaItem, slug, {
1246
+ entryTypeName
1247
+ });
1248
+ await fs3.mkdir(path5.dirname(absolutePath), { recursive: true });
1249
+ if (input.format === "json") {
1250
+ const json = JSON.stringify(input.data ?? {}, null, 2);
1251
+ await atomicWriteFile(absolutePath, `${json}
1252
+ `);
1253
+ if (id) {
1254
+ const existing = idIndex.findById(id);
1255
+ if (existing) {
1256
+ if (existing.relativePath !== relativePath) {
1257
+ idIndex.updatePath(existing.id, relativePath);
1258
+ }
1259
+ } else {
1260
+ idIndex.add({
1261
+ type: "entry",
1262
+ relativePath,
1263
+ collection: collectionPath,
1264
+ slug: slug || void 0
1265
+ });
1266
+ }
1267
+ }
1268
+ return {
1269
+ collection: schemaItem.logicalPath,
1270
+ collectionName: schemaItem.name,
1271
+ format: "json",
1272
+ data: input.data ?? {},
1273
+ relativePath,
1274
+ absolutePath
1275
+ };
1276
+ }
1277
+ const file = matter.stringify(input.body, input.data ?? {});
1278
+ await atomicWriteFile(absolutePath, file);
1279
+ if (id) {
1280
+ const existing = idIndex.findById(id);
1281
+ if (existing) {
1282
+ if (existing.relativePath !== relativePath) {
1283
+ idIndex.updatePath(existing.id, relativePath);
1284
+ }
1285
+ } else {
1286
+ idIndex.add({
1287
+ type: "entry",
1288
+ relativePath,
1289
+ collection: collectionPath,
1290
+ slug: slug || void 0
1291
+ });
1292
+ }
1293
+ }
1294
+ return {
1295
+ collection: schemaItem.logicalPath,
1296
+ collectionName: schemaItem.name,
1297
+ format: input.format,
1298
+ data: input.data ?? {},
1299
+ body: input.body,
1300
+ relativePath,
1301
+ absolutePath
1302
+ };
1303
+ }
1304
+ /**
1305
+ * Read an entry by its ID (UUID).
1306
+ * Returns null if the ID doesn't exist or points to a collection.
1307
+ */
1308
+ async readById(id) {
1309
+ const idIndex = await this.idIndex();
1310
+ const location = idIndex.findById(id);
1311
+ if (!location || location.type !== "entry")
1312
+ return null;
1313
+ return this.read(location.collection, location.slug);
1314
+ }
1315
+ /**
1316
+ * Get the ID for an entry given its collection and slug.
1317
+ * Returns null if no ID exists yet.
1318
+ */
1319
+ async getIdForEntry(collectionPath, slug) {
1320
+ const idIndex = await this.idIndex();
1321
+ const { relativePath } = await this.buildPaths(this.assertCollection(collectionPath), slug);
1322
+ return idIndex.findByPath(relativePath);
1323
+ }
1324
+ /**
1325
+ * Delete an entry and remove it from the index.
1326
+ */
1327
+ async delete(collectionPath, slug) {
1328
+ const idIndex = await this.idIndex();
1329
+ const collection = this.assertCollection(collectionPath);
1330
+ const { absolutePath, relativePath } = await this.buildPaths(collection, slug);
1331
+ const id = idIndex.findByPath(relativePath);
1332
+ await fs3.unlink(absolutePath);
1333
+ if (id) {
1334
+ idIndex.remove(id);
1335
+ }
1336
+ }
1337
+ /**
1338
+ * Rename an entry by changing its slug (middle segment of filename).
1339
+ * Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
1340
+ *
1341
+ * @param collectionPath - Logical path to the collection
1342
+ * @param currentSlug - Current slug of the entry
1343
+ * @param newSlug - New slug (must be unique within collection)
1344
+ * @returns Object with new logical path
1345
+ * @throws ContentStoreError if entry doesn't exist, new slug conflicts, or validation fails
1346
+ */
1347
+ async renameEntry(collectionPath, currentSlug, newSlug) {
1348
+ const idIndex = await this.idIndex();
1349
+ const collection = this.assertCollection(collectionPath);
1350
+ validateSlug(newSlug);
1351
+ const safeNewSlug = newSlug.replace(/^\/+/, "");
1352
+ if (!safeNewSlug) {
1353
+ throw new ContentStoreError("New slug cannot be empty");
1354
+ }
1355
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(safeNewSlug)) {
1356
+ throw new ContentStoreError("Slug must start with a letter or number and contain only lowercase letters, numbers, and hyphens");
1357
+ }
1358
+ const { absolutePath: currentPath, relativePath: currentRelPath } = await this.buildPaths(collection, currentSlug);
1359
+ try {
1360
+ await fs3.access(currentPath);
1361
+ } catch {
1362
+ throw new ContentStoreError(`Entry not found: ${currentSlug}`);
1363
+ }
1364
+ if (currentSlug === safeNewSlug) {
1365
+ return { newPath: `${collectionPath}/${currentSlug}` };
1366
+ }
1367
+ const currentFilename = path5.basename(currentPath);
1368
+ const parts = currentFilename.split(".");
1369
+ if (parts.length < 4) {
1370
+ throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`);
1371
+ }
1372
+ const entryTypeName = parts[0];
1373
+ const contentId = parts[parts.length - 2];
1374
+ const ext = `.${parts[parts.length - 1]}`;
1375
+ const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
1376
+ const parentDir = path5.dirname(currentPath);
1377
+ const newPath = path5.join(parentDir, newFilename);
1378
+ try {
1379
+ const entries = await fs3.readdir(parentDir, { withFileTypes: true });
1380
+ for (const entry of entries) {
1381
+ if (entry.isDirectory())
1382
+ continue;
1383
+ const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
1384
+ if (existingSlug === safeNewSlug) {
1385
+ throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`);
1386
+ }
1387
+ }
1388
+ } catch (err) {
1389
+ if (err instanceof ContentStoreError) {
1390
+ throw err;
1391
+ }
1392
+ }
1393
+ await fs3.rename(currentPath, newPath);
1394
+ const newRelativePath = path5.relative(this.root, newPath);
1395
+ const entryId = idIndex.findByPath(currentRelPath);
1396
+ if (entryId) {
1397
+ idIndex.updatePath(entryId, newRelativePath);
1398
+ }
1399
+ const newLogicalPath = `${collectionPath}/${safeNewSlug}`;
1400
+ return { newPath: newLogicalPath };
1401
+ }
1402
+ /**
1403
+ * List all entries in a collection.
1404
+ * Returns array of entry metadata (relativePath, collection, slug).
1405
+ * Returns empty array if the collection doesn't exist.
1406
+ */
1407
+ async listCollectionEntries(collectionPath) {
1408
+ const idIndex = await this.idIndex();
1409
+ const normalized = normalizeFilesystemPath(collectionPath);
1410
+ let item = this.schemaIndex.get(normalized);
1411
+ if (!item) {
1412
+ for (const schemaItem of this.schemaIndex.values()) {
1413
+ if (schemaItem.type === "collection") {
1414
+ const lastSegment = schemaItem.logicalPath.split("/").pop();
1415
+ if (lastSegment === collectionPath) {
1416
+ item = schemaItem;
1417
+ break;
1418
+ }
1419
+ }
1420
+ }
1421
+ }
1422
+ if (!item || item.type !== "collection") {
1423
+ return [];
1424
+ }
1425
+ const collection = item;
1426
+ const baseEntries = idIndex.getEntriesInCollection(collection.logicalPath);
1427
+ const entries = [];
1428
+ for (const location of baseEntries) {
1429
+ if (location.type === "entry" && location.slug) {
1430
+ if (location.collection === collection.logicalPath || location.collection?.startsWith(collection.logicalPath + "/")) {
1431
+ entries.push({
1432
+ relativePath: location.relativePath,
1433
+ collection: location.collection,
1434
+ slug: location.slug
1435
+ });
1436
+ }
1437
+ }
1438
+ }
1439
+ return entries;
1440
+ }
1441
+ /**
1442
+ * Recursively resolve reference fields in data.
1443
+ * This traverses objects, arrays, and blocks to find and resolve all reference fields.
1444
+ */
1445
+ async resolveReferencesInData(data, fields) {
1446
+ const resolved = { ...data };
1447
+ const idIndex = await this.idIndex();
1448
+ for (const field of fields) {
1449
+ const value = data[field.name];
1450
+ if (field.type === "reference") {
1451
+ if (typeof value === "string" && value) {
1452
+ resolved[field.name] = await this.resolveSingleReference(value, idIndex);
1453
+ } else if (field.list && Array.isArray(value)) {
1454
+ resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex) : null));
1455
+ }
1456
+ } else if (field.type === "object" && value) {
1457
+ const objectField = field;
1458
+ if (!objectField.fields)
1459
+ continue;
1460
+ if (objectField.list && Array.isArray(value)) {
1461
+ resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields) : item));
1462
+ } else if (typeof value === "object") {
1463
+ resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields);
1464
+ }
1465
+ } else if (field.type === "block" && Array.isArray(value)) {
1466
+ const blockField = field;
1467
+ resolved[field.name] = await Promise.all(value.map(async (block) => {
1468
+ const b = block;
1469
+ if (!b || typeof b.value !== "object")
1470
+ return block;
1471
+ const template = blockField.templates.find((t) => t.name === b.template);
1472
+ if (!template)
1473
+ return block;
1474
+ return {
1475
+ ...b,
1476
+ value: await this.resolveReferencesInData(b.value, template.fields)
1477
+ };
1478
+ }));
1479
+ }
1480
+ }
1481
+ return resolved;
1482
+ }
1483
+ /**
1484
+ * Resolve a single reference ID to full entry data.
1485
+ * Returns null if the reference is invalid or missing.
1486
+ * Includes id, slug, and collection fields for debugging.
1487
+ */
1488
+ async resolveSingleReference(id, idIndex) {
1489
+ try {
1490
+ const location = idIndex.findById(id);
1491
+ if (!location || location.type !== "entry" || !location.collection || !location.slug) {
1492
+ return null;
1493
+ }
1494
+ const doc = await this.read(location.collection, location.slug, {
1495
+ resolveReferences: false
1496
+ });
1497
+ return {
1498
+ id,
1499
+ slug: location.slug,
1500
+ collection: location.collection,
1501
+ ...doc.data
1502
+ };
1503
+ } catch (error) {
1504
+ console.error(`Failed to resolve reference ${id}:`, error);
1505
+ return null;
1506
+ }
1507
+ }
1508
+ };
1509
+
1510
+ // dist/branch-schema-cache.js
1511
+ import fs5 from "node:fs/promises";
1512
+ import path6 from "node:path";
1513
+
1514
+ // dist/schema/meta-loader.js
1515
+ import { promises as fs4 } from "fs";
1516
+ import { join as join2 } from "pathe";
1517
+ import { z as z6 } from "zod";
1518
+ import chokidar from "chokidar";
1519
+ var entryTypeMetaSchema = z6.object({
1520
+ name: z6.string().min(1),
1521
+ format: z6.enum(["md", "mdx", "json"]),
1522
+ schema: z6.string().min(1),
1523
+ // Entry schema registry key (validated at resolution time)
1524
+ label: z6.string().optional(),
1525
+ default: z6.boolean().optional(),
1526
+ maxItems: z6.number().int().positive().optional()
1527
+ });
1528
+ var collectionMetaSchema = z6.object({
1529
+ name: z6.string().min(1),
1530
+ label: z6.string().optional(),
1531
+ entries: z6.array(entryTypeMetaSchema).optional(),
1532
+ order: z6.array(z6.string())
1533
+ // Embedded IDs for ordering items (required)
1534
+ }).refine((data) => data.entries && data.entries.length > 0, {
1535
+ message: "Collection must have at least one entry type"
1536
+ });
1537
+ var rootCollectionMetaSchema = z6.object({
1538
+ label: z6.string().optional(),
1539
+ entries: z6.array(entryTypeMetaSchema).optional(),
1540
+ order: z6.array(z6.string()).optional()
1541
+ // Embedded IDs for ordering items
1542
+ });
1543
+ function stripEmbeddedIdFromName(name) {
1544
+ return extractSlugFromFilename(name);
1545
+ }
1546
+ async function scanForCollectionMeta(baseDir, relativePath = "") {
1547
+ const collections = [];
1548
+ try {
1549
+ const entries = await fs4.readdir(baseDir, { withFileTypes: true });
1550
+ for (const entry of entries) {
1551
+ if (!entry.isDirectory())
1552
+ continue;
1553
+ const folderName = entry.name;
1554
+ const logicalName = stripEmbeddedIdFromName(folderName);
1555
+ const collectionContentId = extractIdFromFilename(folderName) ?? void 0;
1556
+ const folderPath = relativePath ? `${relativePath}/${logicalName}` : logicalName;
1557
+ const absolutePath = join2(baseDir, folderName);
1558
+ const metaPath = join2(absolutePath, ".collection.json");
1559
+ try {
1560
+ await fs4.access(metaPath);
1561
+ const content = await fs4.readFile(metaPath, "utf-8");
1562
+ const parsed = JSON.parse(content);
1563
+ const meta = collectionMetaSchema.parse(parsed);
1564
+ collections.push({
1565
+ ...meta,
1566
+ path: folderPath,
1567
+ // Path derived from folder name
1568
+ contentId: collectionContentId
1569
+ });
1570
+ const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
1571
+ collections.push(...nestedCollections);
1572
+ } catch (err) {
1573
+ if (err.code !== "ENOENT") {
1574
+ console.error(`Error loading ${metaPath}:`, err);
1575
+ throw new Error(`Invalid .collection.json in ${folderPath}: ${err.message}`);
1576
+ }
1577
+ const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
1578
+ collections.push(...nestedCollections);
1579
+ }
1580
+ }
1581
+ return collections;
1582
+ } catch (err) {
1583
+ if (err.code === "ENOENT") {
1584
+ return [];
1585
+ }
1586
+ throw err;
1587
+ }
1588
+ }
1589
+ async function loadCollectionMetaFiles(contentRoot) {
1590
+ let root = null;
1591
+ const rootMetaPath = join2(contentRoot, ".collection.json");
1592
+ try {
1593
+ await fs4.access(rootMetaPath);
1594
+ } catch (err) {
1595
+ if (err.code === "ENOENT") {
1596
+ } else {
1597
+ throw err;
1598
+ }
1599
+ }
1600
+ try {
1601
+ const content = await fs4.readFile(rootMetaPath, "utf-8");
1602
+ const parsed = JSON.parse(content);
1603
+ root = rootCollectionMetaSchema.parse(parsed);
1604
+ } catch (err) {
1605
+ const errno = err.code;
1606
+ if (errno !== "ENOENT") {
1607
+ throw new Error(`Invalid root .collection.json`);
1608
+ }
1609
+ }
1610
+ const collections = await scanForCollectionMeta(contentRoot);
1611
+ return { root, collections };
1612
+ }
1613
+ function resolveEntryTypes(entryTypes, entrySchemaRegistry, contextName) {
1614
+ return entryTypes.map((entryType) => {
1615
+ const resolvedSchema = entrySchemaRegistry[entryType.schema];
1616
+ if (!resolvedSchema) {
1617
+ throw new Error(`Schema reference "${entryType.schema}" in entry type "${entryType.name}" (${contextName}) not found in registry. Available schemas: ${Object.keys(entrySchemaRegistry).join(", ")}`);
1618
+ }
1619
+ return {
1620
+ name: entryType.name,
1621
+ label: entryType.label,
1622
+ format: entryType.format,
1623
+ schema: resolvedSchema,
1624
+ schemaRef: entryType.schema,
1625
+ default: entryType.default,
1626
+ maxItems: entryType.maxItems
1627
+ };
1628
+ });
1629
+ }
1630
+ function resolveCollectionMeta(meta, entrySchemaRegistry, allCollections) {
1631
+ const entries = meta.entries && meta.entries.length > 0 ? resolveEntryTypes(meta.entries, entrySchemaRegistry, `collection "${meta.name}"`) : void 0;
1632
+ const nestedCollections = allCollections.filter((col) => {
1633
+ return col.path.startsWith(`${meta.path}/`) && col.path.split("/").length === meta.path.split("/").length + 1;
1634
+ });
1635
+ const collections = nestedCollections.length > 0 ? nestedCollections.map((nestedMeta) => resolveCollectionMeta(nestedMeta, entrySchemaRegistry, allCollections)) : void 0;
1636
+ return {
1637
+ name: meta.name,
1638
+ label: meta.label,
1639
+ path: meta.path,
1640
+ contentId: meta.contentId,
1641
+ ...entries && { entries },
1642
+ ...meta.order && { order: meta.order },
1643
+ ...collections && { collections }
1644
+ };
1645
+ }
1646
+ function resolveCollectionReferences(metaFiles, entrySchemaRegistry) {
1647
+ const result = {};
1648
+ if (metaFiles.root?.label) {
1649
+ result.label = metaFiles.root.label;
1650
+ }
1651
+ if (metaFiles.root?.entries && metaFiles.root.entries.length > 0) {
1652
+ result.entries = resolveEntryTypes(metaFiles.root.entries, entrySchemaRegistry, "root collection");
1653
+ }
1654
+ if (metaFiles.root?.order) {
1655
+ result.order = metaFiles.root.order;
1656
+ }
1657
+ const topLevelCollections = metaFiles.collections.filter((meta) => !meta.path.includes("/"));
1658
+ if (topLevelCollections.length > 0) {
1659
+ result.collections = topLevelCollections.map((meta) => resolveCollectionMeta(meta, entrySchemaRegistry, metaFiles.collections));
1660
+ }
1661
+ return result;
1662
+ }
1663
+
1664
+ // dist/schema/resolver.js
1665
+ async function resolveSchema(contentRoot, entrySchemaRegistry) {
1666
+ const metaFiles = await loadCollectionMetaFiles(contentRoot);
1667
+ const sources = [];
1668
+ if (metaFiles.root) {
1669
+ sources.push({
1670
+ path: ".collection.json",
1671
+ type: "root",
1672
+ collections: []
1673
+ });
1674
+ }
1675
+ for (const collection of metaFiles.collections) {
1676
+ sources.push({
1677
+ path: `${collection.path}/.collection.json`,
1678
+ type: "collection",
1679
+ collections: [collection.name]
1680
+ });
1681
+ }
1682
+ const schema = resolveCollectionReferences(metaFiles, entrySchemaRegistry);
1683
+ return { schema, sources };
1684
+ }
1685
+ function isValidSchema(schema) {
1686
+ const hasEntries = !!(schema.entries && schema.entries.length > 0);
1687
+ const hasCollections = !!(schema.collections && schema.collections.length > 0);
1688
+ return hasEntries || hasCollections;
1689
+ }
1690
+
1691
+ // dist/branch-schema-cache.js
1692
+ var SCHEMA_CACHE_VERSION = 2;
1693
+ var BranchSchemaCache = class {
1694
+ constructor(mode) {
1695
+ this.mode = mode;
1696
+ }
1697
+ /**
1698
+ * Get schema for a branch (loads from cache or resolves fresh).
1699
+ *
1700
+ * @param branchRoot - Root directory of the branch (e.g., .canopy-prod-sim/content-branches/main)
1701
+ * @param entrySchemaRegistry - Map of schema names to field definitions
1702
+ * @param contentRootName - Name of content directory (e.g., "content") from config
1703
+ * @returns Resolved schema tree and flattened schema
1704
+ */
1705
+ async getSchema(branchRoot, entrySchemaRegistry, contentRootName = "content") {
1706
+ if (this.mode === "dev") {
1707
+ if (!this.devModeCache) {
1708
+ const contentRoot = path6.join(branchRoot, contentRootName);
1709
+ const result = await resolveSchema(contentRoot, entrySchemaRegistry);
1710
+ if (!isValidSchema(result.schema)) {
1711
+ throw new Error(`No schema found in ${contentRoot}. Create .collection.json files with references to field schemas defined in your entry schema registry.`);
1712
+ }
1713
+ const flatSchema = flattenSchema(result.schema, contentRootName);
1714
+ this.devModeCache = {
1715
+ schema: result.schema,
1716
+ flatSchema
1717
+ };
1718
+ }
1719
+ return this.devModeCache;
1720
+ }
1721
+ return this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName);
1722
+ }
1723
+ /**
1724
+ * Load schema from cache or resolve fresh if cache is missing or stale.
1725
+ */
1726
+ async loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName) {
1727
+ const contentRoot = path6.join(branchRoot, contentRootName);
1728
+ const cacheDir = path6.join(branchRoot, ".canopy-meta");
1729
+ const cachePath = path6.join(cacheDir, "schema-cache.json");
1730
+ const stalePath = path6.join(cacheDir, "schema-cache.stale");
1731
+ let cacheData = null;
1732
+ try {
1733
+ const staleExists = await fs5.access(stalePath).then(() => true).catch(() => false);
1734
+ if (!staleExists) {
1735
+ const cacheContent = await fs5.readFile(cachePath, "utf-8");
1736
+ cacheData = JSON.parse(cacheContent);
1737
+ }
1738
+ } catch {
1739
+ cacheData = null;
1740
+ }
1741
+ if (cacheData && cacheData.version === SCHEMA_CACHE_VERSION) {
1742
+ return { schema: cacheData.schema, flatSchema: cacheData.flatSchema };
1743
+ }
1744
+ const result = await resolveSchema(contentRoot, entrySchemaRegistry);
1745
+ if (!isValidSchema(result.schema)) {
1746
+ throw new Error(`No schema found in ${contentRoot}. Create .collection.json files with references to field schemas defined in your entry schema registry.`);
1747
+ }
1748
+ const flatSchema = flattenSchema(result.schema, contentRootName);
1749
+ await fs5.mkdir(cacheDir, { recursive: true });
1750
+ const newCache = {
1751
+ version: SCHEMA_CACHE_VERSION,
1752
+ schema: result.schema,
1753
+ flatSchema,
1754
+ cachedAt: (/* @__PURE__ */ new Date()).toISOString()
1755
+ };
1756
+ const tmpPath = path6.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
1757
+ await fs5.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
1758
+ await fs5.rename(tmpPath, cachePath);
1759
+ try {
1760
+ await fs5.unlink(stalePath);
1761
+ } catch {
1762
+ }
1763
+ return { schema: result.schema, flatSchema };
1764
+ }
1765
+ /**
1766
+ * Invalidate cache for a branch (creates .stale marker).
1767
+ *
1768
+ * @param branchRoot - Root directory of the branch
1769
+ */
1770
+ async invalidate(branchRoot) {
1771
+ if (this.mode === "dev") {
1772
+ this.devModeCache = void 0;
1773
+ return;
1774
+ }
1775
+ const cacheDir = path6.join(branchRoot, ".canopy-meta");
1776
+ const stalePath = path6.join(cacheDir, "schema-cache.stale");
1777
+ await fs5.mkdir(cacheDir, { recursive: true });
1778
+ await fs5.writeFile(stalePath, "", "utf-8");
1779
+ }
1780
+ /**
1781
+ * Clear all caches (for testing).
1782
+ * In dev mode, clears in-memory cache.
1783
+ * In prod/prod-sim modes, this would need to traverse all branch directories.
1784
+ */
1785
+ async clearAll() {
1786
+ if (this.mode === "dev") {
1787
+ this.devModeCache = void 0;
1788
+ }
1789
+ }
1790
+ };
1791
+
1792
+ // dist/ai/generate.js
1793
+ import path7 from "node:path";
1794
+ import { minimatch } from "minimatch";
1795
+
1796
+ // dist/ai/json-to-markdown.js
1797
+ function entryToMarkdown(entry, config) {
1798
+ const parts = [];
1799
+ parts.push("---");
1800
+ if (entry.data.title) {
1801
+ parts.push(`title: ${yamlValue(String(entry.data.title))}`);
1802
+ }
1803
+ parts.push(`slug: ${yamlValue(entry.slug)}`);
1804
+ parts.push(`collection: ${yamlValue(entry.collection)}`);
1805
+ parts.push(`type: ${yamlValue(entry.entryType)}`);
1806
+ parts.push("---");
1807
+ parts.push("");
1808
+ const skipFields = /* @__PURE__ */ new Set();
1809
+ if (entry.data.title)
1810
+ skipFields.add("title");
1811
+ if (entry.format === "md" || entry.format === "mdx") {
1812
+ parts.push(...renderMarkdownEntry(entry, config, skipFields));
1813
+ } else {
1814
+ parts.push(...renderJsonEntry(entry, config, skipFields));
1815
+ }
1816
+ return parts.join("\n");
1817
+ }
1818
+ function renderMarkdownEntry(entry, config, skipFields) {
1819
+ const parts = [];
1820
+ const bodyFieldTypes = /* @__PURE__ */ new Set(["rich-text", "markdown", "mdx"]);
1821
+ const metadataFields = entry.fields.filter((f) => !bodyFieldTypes.has(f.type) && !skipFields.has(f.name));
1822
+ for (const field of metadataFields) {
1823
+ const value = entry.data[field.name];
1824
+ if (value === void 0 || value === null)
1825
+ continue;
1826
+ const transformed = applyFieldTransform(entry, field, value, config);
1827
+ if (transformed !== void 0) {
1828
+ parts.push(transformed);
1829
+ parts.push("");
1830
+ continue;
1831
+ }
1832
+ const label = field.label || field.name;
1833
+ parts.push(`**${label}:** ${formatInlineValue(field, value)}`);
1834
+ }
1835
+ if (parts.length > 0) {
1836
+ parts.push("");
1837
+ }
1838
+ if (entry.body) {
1839
+ parts.push(entry.body.trim());
1840
+ parts.push("");
1841
+ }
1842
+ return parts;
1843
+ }
1844
+ function renderJsonEntry(entry, config, skipFields) {
1845
+ const parts = [];
1846
+ for (const field of entry.fields) {
1847
+ if (skipFields.has(field.name))
1848
+ continue;
1849
+ const value = entry.data[field.name];
1850
+ if (value === void 0 || value === null)
1851
+ continue;
1852
+ const rendered = renderField(field, value, 2, entry, config);
1853
+ if (rendered) {
1854
+ parts.push(rendered);
1855
+ parts.push("");
1856
+ }
1857
+ }
1858
+ return parts;
1859
+ }
1860
+ function renderField(field, value, depth, entry, config) {
1861
+ const transformed = applyFieldTransform(entry, field, value, config);
1862
+ if (transformed !== void 0) {
1863
+ return transformed;
1864
+ }
1865
+ const label = field.label || field.name;
1866
+ const heading = "#".repeat(Math.min(depth, 6));
1867
+ const descriptionLine = "description" in field && field.description ? `
1868
+
1869
+ *${field.description}*` : "";
1870
+ if (field.list && Array.isArray(value)) {
1871
+ return renderListField(field, value, depth, label, heading, descriptionLine, entry, config);
1872
+ }
1873
+ switch (field.type) {
1874
+ case "string":
1875
+ case "number":
1876
+ case "datetime":
1877
+ return `${heading} ${label}${descriptionLine}
1878
+
1879
+ ${String(value)}`;
1880
+ case "boolean":
1881
+ return `${heading} ${label}${descriptionLine}
1882
+
1883
+ ${value ? "Yes" : "No"}`;
1884
+ case "rich-text":
1885
+ case "markdown":
1886
+ case "mdx":
1887
+ return `${heading} ${label}${descriptionLine}
1888
+
1889
+ ${String(value)}`;
1890
+ case "image":
1891
+ return `${heading} ${label}${descriptionLine}
1892
+
1893
+ ![${label}](${String(value)})`;
1894
+ case "code":
1895
+ return `${heading} ${label}${descriptionLine}
1896
+
1897
+ \`\`\`
1898
+ ${String(value)}
1899
+ \`\`\``;
1900
+ case "select":
1901
+ return renderSelectField(field, value, heading, label, descriptionLine);
1902
+ case "reference":
1903
+ return renderReferenceField(value, heading, label, descriptionLine);
1904
+ case "object":
1905
+ return renderObjectField(field, value, depth, heading, label, descriptionLine, entry, config);
1906
+ case "block":
1907
+ return renderBlockField(field, value, depth, heading, label, descriptionLine, entry, config);
1908
+ default:
1909
+ return `${heading} ${label}${descriptionLine}
1910
+
1911
+ ${String(value)}`;
1912
+ }
1913
+ }
1914
+ function renderListField(field, values, depth, label, heading, descriptionLine, entry, config) {
1915
+ if (values.length === 0)
1916
+ return "";
1917
+ const isComplex = field.type === "object" || field.type === "block";
1918
+ if (isComplex) {
1919
+ const items2 = values.map((item, i) => {
1920
+ const itemLabel = `${label} ${i + 1}`;
1921
+ const itemHeading = "#".repeat(Math.min(depth + 1, 6));
1922
+ if (field.type === "object" && typeof item === "object" && item !== null) {
1923
+ const objectField = field;
1924
+ const subFields = objectField.fields.map((f) => {
1925
+ const v = item[f.name];
1926
+ if (v === void 0 || v === null)
1927
+ return "";
1928
+ return renderField(f, v, depth + 2, entry, config);
1929
+ }).filter(Boolean);
1930
+ return `${itemHeading} ${itemLabel}
1931
+
1932
+ ${subFields.join("\n\n")}`;
1933
+ }
1934
+ return `${itemHeading} ${itemLabel}
1935
+
1936
+ ${String(item)}`;
1937
+ }).filter(Boolean);
1938
+ return `${heading} ${label}${descriptionLine}
1939
+
1940
+ ${items2.join("\n\n")}`;
1941
+ }
1942
+ const items = values.map((v) => `- ${formatInlineValue(field, v)}`).join("\n");
1943
+ return `${heading} ${label}${descriptionLine}
1944
+
1945
+ ${items}`;
1946
+ }
1947
+ function renderSelectField(field, value, heading, label, descriptionLine) {
1948
+ if (Array.isArray(value)) {
1949
+ return `${heading} ${label}${descriptionLine}
1950
+
1951
+ ${value.map((v) => resolveSelectLabel(field, v)).join(", ")}`;
1952
+ }
1953
+ return `${heading} ${label}${descriptionLine}
1954
+
1955
+ ${resolveSelectLabel(field, value)}`;
1956
+ }
1957
+ function resolveSelectLabel(field, value) {
1958
+ const strValue = String(value);
1959
+ for (const opt of field.options) {
1960
+ if (typeof opt === "string") {
1961
+ if (opt === strValue)
1962
+ return opt;
1963
+ } else {
1964
+ if (opt.value === strValue)
1965
+ return opt.label;
1966
+ }
1967
+ }
1968
+ return strValue;
1969
+ }
1970
+ function renderReferenceField(value, heading, label, descriptionLine) {
1971
+ if (Array.isArray(value)) {
1972
+ const items = value.map((v) => `- ${formatReference(v)}`).join("\n");
1973
+ return `${heading} ${label}${descriptionLine}
1974
+
1975
+ ${items}`;
1976
+ }
1977
+ return `${heading} ${label}${descriptionLine}
1978
+
1979
+ ${formatReference(value)}`;
1980
+ }
1981
+ function formatReference(value) {
1982
+ if (typeof value === "object" && value !== null) {
1983
+ const ref = value;
1984
+ const display = ref.title || ref.name || ref.slug || ref.id;
1985
+ if (display)
1986
+ return String(display);
1987
+ }
1988
+ return String(value);
1989
+ }
1990
+ function renderObjectField(field, value, depth, heading, label, descriptionLine, entry, config) {
1991
+ if (typeof value !== "object" || value === null) {
1992
+ return `${heading} ${label}${descriptionLine}
1993
+
1994
+ ${String(value)}`;
1995
+ }
1996
+ const obj = value;
1997
+ const subFields = field.fields.map((f) => {
1998
+ const v = obj[f.name];
1999
+ if (v === void 0 || v === null)
2000
+ return "";
2001
+ return renderField(f, v, depth + 1, entry, config);
2002
+ }).filter(Boolean);
2003
+ if (subFields.length === 0)
2004
+ return "";
2005
+ return `${heading} ${label}${descriptionLine}
2006
+
2007
+ ${subFields.join("\n\n")}`;
2008
+ }
2009
+ function renderBlockField(field, value, depth, heading, label, descriptionLine, entry, config) {
2010
+ if (!Array.isArray(value))
2011
+ return "";
2012
+ const items = value.map((item) => {
2013
+ if (typeof item !== "object" || item === null)
2014
+ return "";
2015
+ const blockItem = item;
2016
+ const templateName = blockItem._type || blockItem.template;
2017
+ if (!templateName)
2018
+ return "";
2019
+ const template = field.templates.find((t) => t.name === templateName);
2020
+ if (!template)
2021
+ return "";
2022
+ const blockHeading = "#".repeat(Math.min(depth + 1, 6));
2023
+ const blockLabel = template.label || template.name;
2024
+ const blockFields = template.fields.map((f) => {
2025
+ const v = blockItem[f.name] ?? blockItem.value?.[f.name];
2026
+ if (v === void 0 || v === null)
2027
+ return "";
2028
+ return renderField(f, v, depth + 2, entry, config);
2029
+ }).filter(Boolean);
2030
+ if (blockFields.length === 0)
2031
+ return "";
2032
+ return `${blockHeading} ${blockLabel}
2033
+
2034
+ ${blockFields.join("\n\n")}`;
2035
+ }).filter(Boolean);
2036
+ if (items.length === 0)
2037
+ return "";
2038
+ return `${heading} ${label}${descriptionLine}
2039
+
2040
+ ${items.join("\n\n")}`;
2041
+ }
2042
+ function applyFieldTransform(entry, field, value, config) {
2043
+ if (!config?.fieldTransforms)
2044
+ return void 0;
2045
+ const typeTransforms = config.fieldTransforms[entry.entryType];
2046
+ if (!typeTransforms)
2047
+ return void 0;
2048
+ const fn = typeTransforms[field.name];
2049
+ if (!fn)
2050
+ return void 0;
2051
+ return fn(value, field);
2052
+ }
2053
+ function formatInlineValue(field, value) {
2054
+ if (field.type === "boolean")
2055
+ return value ? "Yes" : "No";
2056
+ if (field.type === "reference")
2057
+ return formatReference(value);
2058
+ return String(value);
2059
+ }
2060
+ function yamlValue(value) {
2061
+ if (/[:#{}[\],&*?|>!%@`]/.test(value) || value.includes("\n")) {
2062
+ return `"${value.replace(/"/g, '\\"')}"`;
2063
+ }
2064
+ return value;
2065
+ }
2066
+
2067
+ // dist/ai/generate.js
2068
+ async function generateAIContent(options) {
2069
+ const { store, flatSchema, contentRoot, config } = options;
2070
+ const files = /* @__PURE__ */ new Map();
2071
+ const collections = flatSchema.filter((item) => item.type === "collection");
2072
+ const allEntries = [];
2073
+ const manifestCollections = [];
2074
+ const rootEntries = [];
2075
+ for (const collection of collections) {
2076
+ if (collection.logicalPath === contentRoot)
2077
+ continue;
2078
+ if (isCollectionExcluded(collection.logicalPath, contentRoot, config))
2079
+ continue;
2080
+ if (collection.parentPath && collection.parentPath !== contentRoot)
2081
+ continue;
2082
+ const collectionResult = await processCollection(store, collection, flatSchema, contentRoot, config);
2083
+ allEntries.push(...collectionResult.entries);
2084
+ for (const [filePath, content] of collectionResult.files) {
2085
+ files.set(filePath, content);
2086
+ }
2087
+ manifestCollections.push(collectionResult.manifestCollection);
2088
+ }
2089
+ const rootCollection = collections.find((c) => c.logicalPath === contentRoot);
2090
+ if (rootCollection?.entries) {
2091
+ const rootResult = await processRootEntries(store, rootCollection, contentRoot, config);
2092
+ allEntries.push(...rootResult.entries);
2093
+ for (const [filePath, content] of rootResult.files) {
2094
+ files.set(filePath, content);
2095
+ }
2096
+ rootEntries.push(...rootResult.manifestEntries);
2097
+ }
2098
+ const manifestBundles = [];
2099
+ if (config?.bundles) {
2100
+ for (const bundle of config.bundles) {
2101
+ if (/[/\\]|\.\./.test(bundle.name)) {
2102
+ throw new Error(`Invalid bundle name "${bundle.name}": must not contain slashes or ".."`);
2103
+ }
2104
+ const matchingEntries = allEntries.filter((entry) => matchesBundleFilter(entry, bundle.filter, contentRoot));
2105
+ if (matchingEntries.length > 0) {
2106
+ const bundleContent = matchingEntries.map((e) => entryToMarkdown(e, config)).join("\n---\n\n");
2107
+ const bundlePath = `bundles/${bundle.name}.md`;
2108
+ files.set(bundlePath, bundleContent);
2109
+ manifestBundles.push({
2110
+ name: bundle.name,
2111
+ description: bundle.description,
2112
+ file: bundlePath,
2113
+ entryCount: matchingEntries.length
2114
+ });
2115
+ }
2116
+ }
2117
+ }
2118
+ const manifest = {
2119
+ generated: (/* @__PURE__ */ new Date()).toISOString(),
2120
+ entries: rootEntries,
2121
+ collections: manifestCollections,
2122
+ bundles: manifestBundles
2123
+ };
2124
+ files.set("manifest.json", JSON.stringify(manifest, null, 2));
2125
+ return { manifest, files };
2126
+ }
2127
+ async function processCollection(store, collection, flatSchema, contentRoot, config) {
2128
+ const files = /* @__PURE__ */ new Map();
2129
+ const entries = [];
2130
+ const cleanPath = stripContentRoot(collection.logicalPath, contentRoot);
2131
+ const manifestEntries = [];
2132
+ const listed = await store.listCollectionEntries(collection.logicalPath);
2133
+ const directEntries = listed.filter((e) => e.collection === collection.logicalPath);
2134
+ for (const listEntry of directEntries) {
2135
+ const entryTypeName = extractEntryTypeFromFilename(path7.basename(listEntry.relativePath));
2136
+ if (!entryTypeName)
2137
+ continue;
2138
+ if (config?.exclude?.entryTypes?.includes(entryTypeName))
2139
+ continue;
2140
+ const entryTypeConfig = findEntryType(collection, entryTypeName);
2141
+ if (!entryTypeConfig)
2142
+ continue;
2143
+ try {
2144
+ const doc = await store.read(listEntry.collection, listEntry.slug, {
2145
+ resolveReferences: false
2146
+ });
2147
+ const aiEntry = docToAIEntry(doc, listEntry.slug, entryTypeName, entryTypeConfig, cleanPath);
2148
+ if (config?.exclude?.where?.(aiEntry))
2149
+ continue;
2150
+ entries.push(aiEntry);
2151
+ const entryFilePath = `${cleanPath}/${listEntry.slug}.md`;
2152
+ const entryMarkdown = entryToMarkdown(aiEntry, config);
2153
+ files.set(entryFilePath, entryMarkdown);
2154
+ manifestEntries.push({
2155
+ slug: listEntry.slug,
2156
+ title: aiEntry.data.title ? String(aiEntry.data.title) : void 0,
2157
+ file: entryFilePath
2158
+ });
2159
+ } catch (err) {
2160
+ console.warn(`AI content: skipping entry "${listEntry.slug}" in ${collection.logicalPath}:`, getErrorMessage(err));
2161
+ continue;
2162
+ }
2163
+ }
2164
+ const subcollections = flatSchema.filter((item) => item.type === "collection" && item.parentPath === collection.logicalPath);
2165
+ const manifestSubcollections = [];
2166
+ for (const sub of subcollections) {
2167
+ if (isCollectionExcluded(sub.logicalPath, contentRoot, config))
2168
+ continue;
2169
+ const subResult = await processCollection(store, sub, flatSchema, contentRoot, config);
2170
+ entries.push(...subResult.entries);
2171
+ for (const [filePath, content] of subResult.files) {
2172
+ files.set(filePath, content);
2173
+ }
2174
+ manifestSubcollections.push(subResult.manifestCollection);
2175
+ }
2176
+ if (entries.length > 0) {
2177
+ const allContent = entries.map((e) => entryToMarkdown(e, config)).join("\n---\n\n");
2178
+ const allPath = `${cleanPath}/all.md`;
2179
+ files.set(allPath, allContent);
2180
+ }
2181
+ const manifestCollection = {
2182
+ name: collection.name,
2183
+ label: collection.label,
2184
+ description: collection.description,
2185
+ path: cleanPath,
2186
+ allFile: entries.length > 0 ? `${cleanPath}/all.md` : void 0,
2187
+ entryCount: entries.length,
2188
+ entries: manifestEntries,
2189
+ subcollections: manifestSubcollections.length > 0 ? manifestSubcollections : void 0
2190
+ };
2191
+ return { entries, files, manifestCollection };
2192
+ }
2193
+ async function processRootEntries(store, rootCollection, contentRoot, config) {
2194
+ const files = /* @__PURE__ */ new Map();
2195
+ const entries = [];
2196
+ const manifestEntries = [];
2197
+ const listed = await store.listCollectionEntries(rootCollection.logicalPath);
2198
+ const directEntries = listed.filter((e) => e.collection === rootCollection.logicalPath);
2199
+ for (const listEntry of directEntries) {
2200
+ const entryTypeName = extractEntryTypeFromFilename(path7.basename(listEntry.relativePath));
2201
+ if (!entryTypeName)
2202
+ continue;
2203
+ if (config?.exclude?.entryTypes?.includes(entryTypeName))
2204
+ continue;
2205
+ const entryTypeConfig = findEntryType(rootCollection, entryTypeName);
2206
+ if (!entryTypeConfig)
2207
+ continue;
2208
+ try {
2209
+ const doc = await store.read(listEntry.collection, listEntry.slug, {
2210
+ resolveReferences: false
2211
+ });
2212
+ const aiEntry = docToAIEntry(doc, listEntry.slug, entryTypeName, entryTypeConfig, "");
2213
+ if (config?.exclude?.where?.(aiEntry))
2214
+ continue;
2215
+ entries.push(aiEntry);
2216
+ const entryFilePath = `${listEntry.slug}.md`;
2217
+ const entryMarkdown = entryToMarkdown(aiEntry, config);
2218
+ files.set(entryFilePath, entryMarkdown);
2219
+ manifestEntries.push({
2220
+ slug: listEntry.slug,
2221
+ title: aiEntry.data.title ? String(aiEntry.data.title) : void 0,
2222
+ file: entryFilePath
2223
+ });
2224
+ } catch (err) {
2225
+ console.warn(`AI content: skipping root entry "${listEntry.slug}":`, getErrorMessage(err));
2226
+ continue;
2227
+ }
2228
+ }
2229
+ return { entries, files, manifestEntries };
2230
+ }
2231
+ function stripContentRoot(logicalPath, contentRoot) {
2232
+ if (logicalPath.startsWith(contentRoot + "/")) {
2233
+ return logicalPath.slice(contentRoot.length + 1);
2234
+ }
2235
+ return logicalPath;
2236
+ }
2237
+ function isCollectionExcluded(logicalPath, contentRoot, config) {
2238
+ if (!config?.exclude?.collections)
2239
+ return false;
2240
+ const cleanPath = stripContentRoot(logicalPath, contentRoot);
2241
+ return config.exclude.collections.some((pattern) => (
2242
+ // Match against clean path or full logical path
2243
+ minimatch(cleanPath, pattern) || minimatch(logicalPath, pattern)
2244
+ ));
2245
+ }
2246
+ function findEntryType(collection, entryTypeName) {
2247
+ return collection.entries?.find((e) => e.name === entryTypeName);
2248
+ }
2249
+ function docToAIEntry(doc, slug, entryTypeName, entryTypeConfig, cleanCollectionPath) {
2250
+ return {
2251
+ slug,
2252
+ collection: cleanCollectionPath,
2253
+ collectionName: doc.collectionName,
2254
+ entryType: entryTypeName,
2255
+ format: doc.format,
2256
+ data: doc.data,
2257
+ body: doc.format !== "json" ? doc.body : void 0,
2258
+ fields: entryTypeConfig.schema
2259
+ };
2260
+ }
2261
+ function matchesBundleFilter(entry, filter, contentRoot) {
2262
+ if (filter.collections) {
2263
+ const matches = filter.collections.some((pattern) => {
2264
+ const cleanPattern = stripContentRoot(pattern, contentRoot);
2265
+ return entry.collection === cleanPattern || entry.collection === pattern || entry.collection.startsWith(cleanPattern + "/");
2266
+ });
2267
+ if (!matches)
2268
+ return false;
2269
+ }
2270
+ if (filter.entryTypes) {
2271
+ if (!filter.entryTypes.includes(entry.entryType))
2272
+ return false;
2273
+ }
2274
+ if (filter.paths) {
2275
+ const entryPath = entry.collection ? `${entry.collection}/${entry.slug}` : entry.slug;
2276
+ const matches = filter.paths.some((pattern) => minimatch(entryPath, pattern));
2277
+ if (!matches)
2278
+ return false;
2279
+ }
2280
+ if (filter.where) {
2281
+ if (!filter.where(entry))
2282
+ return false;
2283
+ }
2284
+ return true;
2285
+ }
2286
+
2287
+ // dist/branch-metadata.js
2288
+ import { randomUUID } from "node:crypto";
2289
+ import fs7 from "node:fs/promises";
2290
+ import path9 from "node:path";
2291
+
2292
+ // dist/branch-registry.js
2293
+ import fs6 from "node:fs/promises";
2294
+ import path8 from "node:path";
2295
+ var REGISTRY_FILE = "branches.json";
2296
+ var REGISTRY_STALE_FILE = "branches.stale.json";
2297
+ var REGISTRY_TEMP_FILE = "branches.tmp.json";
2298
+ var REGISTRY_VERSION = 1;
2299
+ var BranchRegistry = class {
2300
+ constructor(root) {
2301
+ this.root = path8.resolve(root);
2302
+ this.registryPath = path8.join(this.root, REGISTRY_FILE);
2303
+ this.stalePath = path8.join(this.root, REGISTRY_STALE_FILE);
2304
+ this.tempPath = path8.join(this.root, REGISTRY_TEMP_FILE);
2305
+ }
2306
+ /**
2307
+ * Returns all branches. Uses cache if fresh, regenerates if stale.
2308
+ */
2309
+ async list() {
2310
+ try {
2311
+ const raw = await fs6.readFile(this.registryPath, "utf8");
2312
+ const parsed = JSON.parse(raw);
2313
+ if (!parsed.version || !Array.isArray(parsed.branches)) {
2314
+ return await this.regenerate();
2315
+ }
2316
+ return parsed.branches;
2317
+ } catch (err) {
2318
+ if (isNotFoundError(err)) {
2319
+ return await this.regenerate();
2320
+ }
2321
+ throw err;
2322
+ }
2323
+ }
2324
+ /**
2325
+ * Returns a single branch by name. Uses cache if available.
2326
+ */
2327
+ async get(name) {
2328
+ const branches = await this.list();
2329
+ return branches.find((b) => b.branch.name === name);
2330
+ }
2331
+ /**
2332
+ * Marks the cache as stale. Next list() call will regenerate.
2333
+ * Uses atomic rename for safety.
2334
+ */
2335
+ async invalidate() {
2336
+ try {
2337
+ await fs6.rename(this.registryPath, this.stalePath);
2338
+ } catch (err) {
2339
+ if (!isNotFoundError(err)) {
2340
+ throw err;
2341
+ }
2342
+ }
2343
+ }
2344
+ /**
2345
+ * Scans branch directories and rebuilds the cache.
2346
+ * Concurrent calls are safe - all produce identical content.
2347
+ */
2348
+ async regenerate() {
2349
+ const branches = await this.scanBranchDirectories();
2350
+ const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
2351
+ await fs6.mkdir(this.root, { recursive: true });
2352
+ const snapshot = {
2353
+ version: REGISTRY_VERSION,
2354
+ branches
2355
+ };
2356
+ await fs6.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
2357
+ try {
2358
+ await fs6.rename(uniqueTempPath, this.registryPath);
2359
+ } catch (err) {
2360
+ await fs6.unlink(uniqueTempPath).catch(() => {
2361
+ });
2362
+ throw err;
2363
+ }
2364
+ await fs6.unlink(this.stalePath).catch(() => {
2365
+ });
2366
+ return branches;
2367
+ }
2368
+ /**
2369
+ * Scans the root directory for branch subdirectories with valid branch.json files.
2370
+ */
2371
+ async scanBranchDirectories() {
2372
+ const branches = [];
2373
+ try {
2374
+ const entries = await fs6.readdir(this.root, { withFileTypes: true });
2375
+ for (const entry of entries) {
2376
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
2377
+ continue;
2378
+ }
2379
+ const branchRoot = path8.join(this.root, entry.name);
2380
+ const meta = await BranchMetadataFileManager.loadOnly(branchRoot);
2381
+ if (meta) {
2382
+ branches.push({
2383
+ branch: meta.branch,
2384
+ branchRoot,
2385
+ baseRoot: this.root
2386
+ });
2387
+ }
2388
+ }
2389
+ } catch (err) {
2390
+ if (isNotFoundError(err)) {
2391
+ return [];
2392
+ }
2393
+ throw err;
2394
+ }
2395
+ return branches;
2396
+ }
2397
+ };
2398
+
2399
+ // dist/branch-metadata.js
2400
+ var BRANCH_META_DIR = ".canopy-meta";
2401
+ var BRANCH_META_FILE = "branch.json";
2402
+ var CURRENT_SCHEMA_VERSION = 1;
2403
+ var BranchMetadataConflictError = class extends Error {
2404
+ constructor() {
2405
+ super("Concurrent modification detected in branch metadata");
2406
+ this.name = "BranchMetadataConflictError";
2407
+ }
2408
+ };
2409
+ var fileLocks = /* @__PURE__ */ new Map();
2410
+ async function withFileLock(filePath, fn) {
2411
+ while (fileLocks.has(filePath)) {
2412
+ await fileLocks.get(filePath);
2413
+ }
2414
+ let resolve;
2415
+ const lockPromise = new Promise((r) => {
2416
+ resolve = r;
2417
+ });
2418
+ fileLocks.set(filePath, lockPromise);
2419
+ try {
2420
+ return await fn();
2421
+ } finally {
2422
+ fileLocks.delete(filePath);
2423
+ resolve();
2424
+ }
2425
+ }
2426
+ var BranchMetadataFileManager = class _BranchMetadataFileManager {
2427
+ constructor(branchRoot, baseRoot) {
2428
+ this.branchRoot = path9.resolve(branchRoot);
2429
+ this.filePath = path9.join(this.branchRoot, BRANCH_META_DIR, BRANCH_META_FILE);
2430
+ this.baseRoot = baseRoot;
2431
+ }
2432
+ /**
2433
+ * Load branch metadata without requiring baseRoot.
2434
+ * Use this for read-only access (e.g., in registry scanning or loadBranchContext).
2435
+ */
2436
+ static async loadOnly(branchRoot) {
2437
+ const filePath = path9.join(path9.resolve(branchRoot), BRANCH_META_DIR, BRANCH_META_FILE);
2438
+ try {
2439
+ const raw = await fs7.readFile(filePath, "utf8");
2440
+ return JSON.parse(raw);
2441
+ } catch (err) {
2442
+ if (isNotFoundError(err)) {
2443
+ return null;
2444
+ }
2445
+ throw err;
2446
+ }
2447
+ }
2448
+ /**
2449
+ * Get a BranchMetadataFileManager instance configured for registry invalidation.
2450
+ * Use this in API handlers to ensure registry cache is invalidated on updates.
2451
+ */
2452
+ static get(branchRoot, baseRoot) {
2453
+ return new _BranchMetadataFileManager(branchRoot, baseRoot);
2454
+ }
2455
+ async load() {
2456
+ try {
2457
+ const raw = await fs7.readFile(this.filePath, "utf8");
2458
+ const parsed = JSON.parse(raw);
2459
+ const version = parsed.version ?? 0;
2460
+ return { meta: parsed, version };
2461
+ } catch (err) {
2462
+ if (isNotFoundError(err)) {
2463
+ return { meta: null, version: null };
2464
+ }
2465
+ throw err;
2466
+ }
2467
+ }
2468
+ /**
2469
+ * Atomic write using temp-file + rename + post-write verification.
2470
+ * Follows the same pattern as CommentStore for EFS/NFS safety.
2471
+ */
2472
+ async write(meta, expectedVersion) {
2473
+ const newVersion = expectedVersion === null ? 1 : expectedVersion + 1;
2474
+ const writeId = randomUUID();
2475
+ const payload = {
2476
+ ...meta,
2477
+ schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION,
2478
+ version: newVersion,
2479
+ writeId
2480
+ };
2481
+ await fs7.mkdir(path9.dirname(this.filePath), { recursive: true });
2482
+ const content = JSON.stringify(payload, null, 2) + "\n";
2483
+ if (expectedVersion === null) {
2484
+ try {
2485
+ await fs7.writeFile(this.filePath, content, { flag: "wx" });
2486
+ return { version: newVersion, writeId };
2487
+ } catch (err) {
2488
+ if (isFileExistsError(err)) {
2489
+ throw new BranchMetadataConflictError();
2490
+ }
2491
+ throw err;
2492
+ }
2493
+ }
2494
+ const tempPath = `${this.filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
2495
+ await fs7.writeFile(tempPath, content, "utf-8");
2496
+ try {
2497
+ let currentVersion = null;
2498
+ try {
2499
+ const current = JSON.parse(await fs7.readFile(this.filePath, "utf-8"));
2500
+ currentVersion = current.version ?? 0;
2501
+ } catch {
2502
+ currentVersion = null;
2503
+ }
2504
+ if (currentVersion !== expectedVersion) {
2505
+ throw new BranchMetadataConflictError();
2506
+ }
2507
+ await fs7.rename(tempPath, this.filePath);
2508
+ const afterWrite = JSON.parse(await fs7.readFile(this.filePath, "utf-8"));
2509
+ if (afterWrite.writeId !== writeId) {
2510
+ throw new BranchMetadataConflictError();
2511
+ }
2512
+ } catch (err) {
2513
+ await fs7.unlink(tempPath).catch(() => {
2514
+ });
2515
+ throw err;
2516
+ }
2517
+ return { version: newVersion, writeId };
2518
+ }
2519
+ async withRetry(operation, maxAttempts = 5) {
2520
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2521
+ try {
2522
+ return await operation();
2523
+ } catch (err) {
2524
+ if (err instanceof BranchMetadataConflictError && attempt < maxAttempts) {
2525
+ const baseDelay = Math.min(10 * Math.pow(2, attempt - 1), 100);
2526
+ const jitter = Math.random() * baseDelay;
2527
+ await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
2528
+ continue;
2529
+ }
2530
+ throw err;
2531
+ }
2532
+ }
2533
+ throw new Error("Unreachable");
2534
+ }
2535
+ async save(incoming) {
2536
+ return withFileLock(this.filePath, () => this.withRetry(async () => {
2537
+ const { meta: existing, version } = await this.load();
2538
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2539
+ const defaults = {
2540
+ name: "unknown",
2541
+ status: "editing",
2542
+ access: {},
2543
+ createdBy: "unknown",
2544
+ createdAt: now,
2545
+ updatedAt: now
2546
+ };
2547
+ const merged = {
2548
+ schemaVersion: CURRENT_SCHEMA_VERSION,
2549
+ version: version ?? 0,
2550
+ branch: {
2551
+ ...defaults,
2552
+ ...existing?.branch,
2553
+ ...incoming.branch,
2554
+ access: {
2555
+ ...existing?.branch?.access,
2556
+ ...incoming.branch?.access
2557
+ },
2558
+ // Immutable after creation
2559
+ createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
2560
+ createdAt: existing?.branch.createdAt ?? defaults.createdAt
2561
+ }
2562
+ };
2563
+ const written = await this.write(merged, version);
2564
+ merged.version = written.version;
2565
+ merged.writeId = written.writeId;
2566
+ await this.invalidateRegistry();
2567
+ return merged;
2568
+ }));
2569
+ }
2570
+ /**
2571
+ * Invalidates the registry cache so next list() call regenerates from branch.json files.
2572
+ */
2573
+ async invalidateRegistry() {
2574
+ const registry = new BranchRegistry(this.baseRoot);
2575
+ await registry.invalidate();
2576
+ }
2577
+ };
2578
+ var loadBranchContext = async (options) => {
2579
+ const { branchRoot, baseRoot } = resolveBranchPath({
2580
+ branchName: options.branchName,
2581
+ mode: options.mode,
2582
+ basePathOverride: options.basePathOverride
2583
+ });
2584
+ const meta = await BranchMetadataFileManager.loadOnly(branchRoot);
2585
+ if (!meta) {
2586
+ return null;
2587
+ }
2588
+ return {
2589
+ branch: meta.branch,
2590
+ branchRoot,
2591
+ baseRoot
2592
+ };
2593
+ };
2594
+
2595
+ // dist/ai/resolve-branch.js
2596
+ async function resolveBranchRoot(config) {
2597
+ if (config.mode === "dev") {
2598
+ return process.cwd();
2599
+ }
2600
+ const baseBranch = config.defaultBaseBranch ?? "main";
2601
+ const context = await loadBranchContext({
2602
+ branchName: baseBranch,
2603
+ mode: config.mode
2604
+ });
2605
+ if (!context) {
2606
+ throw new Error(`Could not load branch context for "${baseBranch}". Ensure the branch exists and has been initialized.`);
2607
+ }
2608
+ return context.branchRoot;
2609
+ }
2610
+
2611
+ // dist/build/generate-ai-content.js
2612
+ async function generateAIContentFiles(options) {
2613
+ const { config, entrySchemaRegistry, outputDir, aiConfig, _testFlatSchema } = options;
2614
+ const contentRootName = config.contentRoot || "content";
2615
+ const branchRoot = await resolveBranchRoot(config);
2616
+ let flatSchema;
2617
+ if (_testFlatSchema) {
2618
+ flatSchema = _testFlatSchema;
2619
+ } else {
2620
+ const schemaCache = new BranchSchemaCache(config.mode);
2621
+ const cached = await schemaCache.getSchema(branchRoot, entrySchemaRegistry, contentRootName);
2622
+ flatSchema = cached.flatSchema;
2623
+ }
2624
+ const store = new ContentStore(branchRoot, flatSchema);
2625
+ const result = await generateAIContent({
2626
+ store,
2627
+ flatSchema,
2628
+ contentRoot: contentRootName,
2629
+ config: aiConfig
2630
+ });
2631
+ const absoluteOutputDir = path10.resolve(outputDir) + path10.sep;
2632
+ let fileCount = 0;
2633
+ for (const [filePath, content] of result.files) {
2634
+ const absolutePath = path10.resolve(path10.join(absoluteOutputDir, filePath));
2635
+ if (!absolutePath.startsWith(absoluteOutputDir)) {
2636
+ throw new Error(`Path traversal detected in AI content output: ${filePath}`);
2637
+ }
2638
+ await fs8.mkdir(path10.dirname(absolutePath), { recursive: true });
2639
+ await fs8.writeFile(absolutePath, content, "utf-8");
2640
+ fileCount++;
2641
+ }
2642
+ return { fileCount, outputDir: absoluteOutputDir };
2643
+ }
2644
+
2645
+ // dist/cli/generate-ai-content.js
2646
+ async function generateAIContentCLI(options) {
2647
+ const { projectDir, outputDir = "public/ai", configPath, appDir = "app" } = options;
2648
+ console.log("\nCanopyCMS generate-ai-content\n");
2649
+ const canopyConfigPath = path11.join(projectDir, "canopycms.config.ts");
2650
+ let canopyConfigModule;
2651
+ try {
2652
+ canopyConfigModule = await import(canopyConfigPath);
2653
+ } catch (err) {
2654
+ console.error(`Could not load config from ${canopyConfigPath}`);
2655
+ console.error(getErrorMessage(err));
2656
+ process.exit(1);
2657
+ }
2658
+ const configExport = canopyConfigModule.default ?? canopyConfigModule.config ?? canopyConfigModule;
2659
+ const serverConfig = typeof configExport === "object" && configExport !== null && "server" in configExport ? configExport.server : configExport;
2660
+ const schemasPath = path11.join(projectDir, appDir, "schemas.ts");
2661
+ let entrySchemaRegistry = {};
2662
+ try {
2663
+ const schemasModule = await import(schemasPath);
2664
+ entrySchemaRegistry = schemasModule.entrySchemaRegistry ?? schemasModule;
2665
+ } catch {
2666
+ console.warn(` No ${appDir}/schemas.ts found, using empty entry schema registry`);
2667
+ }
2668
+ let aiConfig;
2669
+ if (configPath) {
2670
+ try {
2671
+ const aiConfigModule = await import(path11.resolve(configPath));
2672
+ aiConfig = aiConfigModule.aiContentConfig ?? aiConfigModule.default ?? aiConfigModule.config;
2673
+ } catch (err) {
2674
+ console.error(`Could not load AI config from ${configPath}`);
2675
+ console.error(getErrorMessage(err));
2676
+ process.exit(1);
2677
+ }
2678
+ }
2679
+ if (aiConfig !== void 0 && (typeof aiConfig !== "object" || aiConfig === null)) {
2680
+ console.error("Invalid AI content config: expected an object.");
2681
+ process.exit(1);
2682
+ }
2683
+ if (!serverConfig || typeof serverConfig !== "object" || !("mode" in serverConfig) || !("contentRoot" in serverConfig)) {
2684
+ console.error("Invalid CanopyCMS config: expected an object with mode and contentRoot properties.");
2685
+ console.error("Make sure canopycms.config.ts uses defineCanopyConfig().");
2686
+ process.exit(1);
2687
+ }
2688
+ const resolvedOutput = path11.resolve(projectDir, outputDir);
2689
+ console.log(` Output: ${resolvedOutput}`);
2690
+ console.log(` Mode: ${serverConfig.mode ?? "dev"}`);
2691
+ const result = await generateAIContentFiles({
2692
+ config: serverConfig,
2693
+ entrySchemaRegistry,
2694
+ outputDir: resolvedOutput,
2695
+ aiConfig
2696
+ });
2697
+ console.log(`
2698
+ Generated ${result.fileCount} files`);
2699
+ console.log(` Output: ${result.outputDir}
2700
+ `);
2701
+ }
2702
+ export {
2703
+ generateAIContentCLI
2704
+ };