@contentrain/mcp 0.3.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +154 -238
  3. package/dist/apply-manager-BAeqrAHX.mjs +751 -0
  4. package/dist/apply-manager-BAeqrAHX.mjs.map +1 -0
  5. package/dist/astro-parser-RofKmKQi.mjs +166 -0
  6. package/dist/astro-parser-RofKmKQi.mjs.map +1 -0
  7. package/dist/branch-lifecycle-DB6sgeJi.mjs +97 -0
  8. package/dist/branch-lifecycle-DB6sgeJi.mjs.map +1 -0
  9. package/dist/chunk-BEJ448es.mjs +35 -0
  10. package/dist/compiler-rIC4ETHv.mjs +44509 -0
  11. package/dist/compiler-rIC4ETHv.mjs.map +1 -0
  12. package/dist/compiler-sfc.cjs-BbD04rn_.mjs +50279 -0
  13. package/dist/compiler-sfc.cjs-BbD04rn_.mjs.map +1 -0
  14. package/dist/config-CPlhDwsv.mjs +27 -0
  15. package/dist/config-CPlhDwsv.mjs.map +1 -0
  16. package/dist/context-CRBCTYPB.mjs +46 -0
  17. package/dist/context-CRBCTYPB.mjs.map +1 -0
  18. package/dist/core/apply-manager.d.mts +144 -0
  19. package/dist/core/apply-manager.d.mts.map +1 -0
  20. package/dist/core/apply-manager.mjs +6 -0
  21. package/dist/core/config.d.mts +8 -0
  22. package/dist/core/config.d.mts.map +1 -0
  23. package/dist/core/config.mjs +2 -0
  24. package/dist/core/content-manager.d.mts +53 -0
  25. package/dist/core/content-manager.d.mts.map +1 -0
  26. package/dist/core/content-manager.mjs +2 -0
  27. package/dist/core/context.d.mts +13 -0
  28. package/dist/core/context.d.mts.map +1 -0
  29. package/dist/core/context.mjs +3 -0
  30. package/dist/core/graph-builder.d.mts +12 -0
  31. package/dist/core/graph-builder.d.mts.map +1 -0
  32. package/dist/core/graph-builder.mjs +2 -0
  33. package/dist/core/meta-manager.d.mts +14 -0
  34. package/dist/core/meta-manager.d.mts.map +1 -0
  35. package/dist/core/meta-manager.mjs +2 -0
  36. package/dist/core/model-manager.d.mts +44 -0
  37. package/dist/core/model-manager.d.mts.map +1 -0
  38. package/dist/core/model-manager.mjs +2 -0
  39. package/dist/core/scan-config.d.mts +24 -0
  40. package/dist/core/scan-config.d.mts.map +1 -0
  41. package/dist/core/scan-config.mjs +2 -0
  42. package/dist/core/scanner.d.mts +25 -0
  43. package/dist/core/scanner.d.mts.map +1 -0
  44. package/dist/core/scanner.mjs +3 -0
  45. package/dist/core/validator.d.mts +23 -0
  46. package/dist/core/validator.d.mts.map +1 -0
  47. package/dist/core/validator.mjs +3 -0
  48. package/dist/detect-C8XmJ4S6.mjs +362 -0
  49. package/dist/detect-C8XmJ4S6.mjs.map +1 -0
  50. package/dist/fs-BbuGnVcG.mjs +52 -0
  51. package/dist/fs-BbuGnVcG.mjs.map +1 -0
  52. package/dist/git/branch-lifecycle.d.mts +28 -0
  53. package/dist/git/branch-lifecycle.d.mts.map +1 -0
  54. package/dist/git/branch-lifecycle.mjs +2 -0
  55. package/dist/git/transaction.d.mts +25 -0
  56. package/dist/git/transaction.d.mts.map +1 -0
  57. package/dist/git/transaction.mjs +4 -0
  58. package/dist/graph-builder-fPrDDwnu.mjs +162 -0
  59. package/dist/graph-builder-fPrDDwnu.mjs.map +1 -0
  60. package/dist/id-BdD4HvMY.mjs +12 -0
  61. package/dist/id-BdD4HvMY.mjs.map +1 -0
  62. package/dist/index.d.mts +1 -359
  63. package/dist/index.mjs +15 -2161
  64. package/dist/index.mjs.map +1 -1
  65. package/dist/meta-manager-CECrfsKm.mjs +53 -0
  66. package/dist/meta-manager-CECrfsKm.mjs.map +1 -0
  67. package/dist/model-manager-sfPdrVIJ.mjs +1016 -0
  68. package/dist/model-manager-sfPdrVIJ.mjs.map +1 -0
  69. package/dist/node-Cbq5eR5i.mjs +300 -0
  70. package/dist/node-Cbq5eR5i.mjs.map +1 -0
  71. package/dist/scan-config-AJGxCdNd.mjs +159 -0
  72. package/dist/scan-config-AJGxCdNd.mjs.map +1 -0
  73. package/dist/scanner-BdTiv8sw.mjs +619 -0
  74. package/dist/scanner-BdTiv8sw.mjs.map +1 -0
  75. package/dist/serializer-Dueih-i2.mjs +33 -0
  76. package/dist/serializer-Dueih-i2.mjs.map +1 -0
  77. package/dist/server-CC12z_xq.mjs +1868 -0
  78. package/dist/server-CC12z_xq.mjs.map +1 -0
  79. package/dist/server.d.mts +7 -0
  80. package/dist/server.d.mts.map +1 -0
  81. package/dist/server.mjs +11 -0
  82. package/dist/source-map-generator-DpO-ejrt.mjs +893 -0
  83. package/dist/source-map-generator-DpO-ejrt.mjs.map +1 -0
  84. package/dist/svelte-parser-COTNG92w.mjs +225 -0
  85. package/dist/svelte-parser-COTNG92w.mjs.map +1 -0
  86. package/dist/templates/index.d.mts +8 -0
  87. package/dist/templates/index.d.mts.map +1 -0
  88. package/dist/templates/index.mjs +2 -0
  89. package/dist/templates-3HWi-PTu.mjs +824 -0
  90. package/dist/templates-3HWi-PTu.mjs.map +1 -0
  91. package/dist/transaction-DXkMulzr.mjs +128 -0
  92. package/dist/transaction-DXkMulzr.mjs.map +1 -0
  93. package/dist/tsx-parser-Bf8Firrq.mjs +2 -0
  94. package/dist/tsx-parser-CYVyBWjn.mjs +234 -0
  95. package/dist/tsx-parser-CYVyBWjn.mjs.map +1 -0
  96. package/dist/util/detect.d.mts +20 -0
  97. package/dist/util/detect.d.mts.map +1 -0
  98. package/dist/util/detect.mjs +2 -0
  99. package/dist/util/fs.d.mts +12 -0
  100. package/dist/util/fs.d.mts.map +1 -0
  101. package/dist/util/fs.mjs +2 -0
  102. package/dist/util/id.d.mts +6 -0
  103. package/dist/util/id.d.mts.map +1 -0
  104. package/dist/util/id.mjs +2 -0
  105. package/dist/util/serializer.d.mts +14 -0
  106. package/dist/util/serializer.d.mts.map +1 -0
  107. package/dist/util/serializer.mjs +2 -0
  108. package/dist/validator-C3lZqO3U.mjs +755 -0
  109. package/dist/validator-C3lZqO3U.mjs.map +1 -0
  110. package/dist/vue-parser-CjS4YAoQ.mjs +225 -0
  111. package/dist/vue-parser-CjS4YAoQ.mjs.map +1 -0
  112. package/package.json +113 -43
  113. package/dist/fs-D83-Ii_y.mjs +0 -75
  114. package/dist/fs-D83-Ii_y.mjs.map +0 -1
  115. package/dist/index.d.mts.map +0 -1
@@ -0,0 +1,1016 @@
1
+ import { a as readJson, c as writeText, i as readDir, n as ensureDir, o as readText, s as writeJson, t as contentrainDir } from "./fs-BbuGnVcG.mjs";
2
+ import { n as generateEntryId } from "./id-BdD4HvMY.mjs";
3
+ import { r as writeMeta, t as deleteMeta } from "./meta-manager-CECrfsKm.mjs";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import { rm } from "node:fs/promises";
7
+ //#region src/core/content-manager.ts
8
+ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
9
+ const ENTRY_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,39}$/;
10
+ const LOCALE_RE = /^[a-z]{2}(?:-[A-Z]{2})?$/;
11
+ function validateSlug(slug) {
12
+ if (!slug) return "Slug is required";
13
+ if (!SLUG_RE.test(slug)) return `Invalid slug "${slug}": must be kebab-case alphanumeric (a-z, 0-9, hyphens)`;
14
+ if (slug.startsWith(".") || slug.includes("..")) return `Invalid slug "${slug}": path traversal not allowed`;
15
+ return null;
16
+ }
17
+ function validateEntryId(id) {
18
+ if (!ENTRY_ID_RE.test(id)) return `Invalid entry ID "${id}": must be 1-40 alphanumeric characters (hyphens and underscores allowed)`;
19
+ return null;
20
+ }
21
+ function validateLocale(locale, config) {
22
+ if (!LOCALE_RE.test(locale)) return `Invalid locale "${locale}": must be ISO 639-1 format (e.g. "en", "tr", "pt-BR")`;
23
+ if (!config.locales.supported.includes(locale)) return `Locale "${locale}" is not in supported locales [${config.locales.supported.join(", ")}]. Update config first.`;
24
+ return null;
25
+ }
26
+ function parseFrontmatter(content) {
27
+ const normalized = content.replace(/\r\n/g, "\n");
28
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
29
+ if (!match) return {
30
+ frontmatter: {},
31
+ body: normalized
32
+ };
33
+ const frontmatterStr = match[1];
34
+ const body = match[2].trim();
35
+ const frontmatter = {};
36
+ let currentKey = null;
37
+ let currentArray = null;
38
+ for (const line of frontmatterStr.split("\n")) {
39
+ if (/^\s+-\s+/.test(line) && currentKey) {
40
+ const value = line.replace(/^\s+-\s+/, "").trim();
41
+ if (!currentArray) currentArray = [];
42
+ currentArray.push(value);
43
+ continue;
44
+ }
45
+ if (currentKey && currentArray) {
46
+ frontmatter[currentKey] = currentArray;
47
+ currentKey = null;
48
+ currentArray = null;
49
+ }
50
+ const kvMatch = line.match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
51
+ if (!kvMatch) continue;
52
+ const key = kvMatch[1];
53
+ const rawValue = kvMatch[2].trim();
54
+ if (rawValue === "") {
55
+ currentKey = key;
56
+ currentArray = [];
57
+ continue;
58
+ }
59
+ if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
60
+ frontmatter[key] = rawValue.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean);
61
+ continue;
62
+ }
63
+ frontmatter[key] = parseYamlValue(rawValue);
64
+ }
65
+ if (currentKey && currentArray) frontmatter[currentKey] = currentArray;
66
+ return {
67
+ frontmatter,
68
+ body
69
+ };
70
+ }
71
+ function parseYamlValue(raw) {
72
+ if (raw === "true") return true;
73
+ if (raw === "false") return false;
74
+ if (raw === "null") return null;
75
+ if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
76
+ if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw);
77
+ if (raw.startsWith("\"") && raw.endsWith("\"") || raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
78
+ return raw;
79
+ }
80
+ function yamlScalar(value) {
81
+ const str = String(value);
82
+ if (str.includes(":") || str.includes("#") || str.includes("{") || str.includes("}") || str.includes("[") || str.includes("]") || str.includes("*") || str.includes("&") || str.includes("!") || str.includes("|") || str.includes(">") || str.includes("'") || str.includes("\"") || str.includes("%") || str.includes("@") || str.includes("`") || str.startsWith("-") || str.startsWith("?") || str === "true" || str === "false" || str === "null" || str === "yes" || str === "no" || str === "") return `"${str.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
83
+ return str;
84
+ }
85
+ function serializeYamlValue(value, indent) {
86
+ const pad = " ".repeat(indent);
87
+ if (value === null || value === void 0) return [];
88
+ if (typeof value === "object" && !Array.isArray(value)) {
89
+ const lines = [];
90
+ for (const [k, v] of Object.entries(value)) {
91
+ if (v === null || v === void 0) continue;
92
+ if (typeof v === "object" && !Array.isArray(v)) {
93
+ lines.push(`${pad}${k}:`);
94
+ lines.push(...serializeYamlValue(v, indent + 1));
95
+ } else if (Array.isArray(v)) {
96
+ lines.push(`${pad}${k}:`);
97
+ for (const item of v) if (typeof item === "object" && item !== null && !Array.isArray(item)) {
98
+ const nested = serializeYamlValue(item, indent + 2);
99
+ if (nested.length > 0) {
100
+ lines.push(`${pad} - ${nested[0].trimStart()}`);
101
+ lines.push(...nested.slice(1));
102
+ }
103
+ } else lines.push(`${pad} - ${yamlScalar(item)}`);
104
+ } else lines.push(`${pad}${k}: ${yamlScalar(v)}`);
105
+ }
106
+ return lines;
107
+ }
108
+ if (Array.isArray(value)) {
109
+ const lines = [];
110
+ for (const item of value) if (typeof item === "object" && item !== null && !Array.isArray(item)) {
111
+ const nested = serializeYamlValue(item, indent + 1);
112
+ if (nested.length > 0) {
113
+ lines.push(`${pad}- ${nested[0].trimStart()}`);
114
+ lines.push(...nested.slice(1));
115
+ }
116
+ } else lines.push(`${pad}- ${yamlScalar(item)}`);
117
+ return lines;
118
+ }
119
+ return [`${pad}${yamlScalar(value)}`];
120
+ }
121
+ function serializeYamlFields(data) {
122
+ const lines = [];
123
+ for (const [key, value] of Object.entries(data)) {
124
+ if (key === "body") continue;
125
+ if (value === null || value === void 0) continue;
126
+ if (typeof value === "object" && !Array.isArray(value)) {
127
+ lines.push(`${key}:`);
128
+ lines.push(...serializeYamlValue(value, 1));
129
+ } else if (Array.isArray(value)) {
130
+ lines.push(`${key}:`);
131
+ for (const item of value) if (typeof item === "object" && item !== null && !Array.isArray(item)) {
132
+ const nested = serializeYamlValue(item, 2);
133
+ if (nested.length > 0) {
134
+ lines.push(` - ${nested[0].trimStart()}`);
135
+ lines.push(...nested.slice(1));
136
+ }
137
+ } else lines.push(` - ${yamlScalar(item)}`);
138
+ } else lines.push(`${key}: ${yamlScalar(value)}`);
139
+ }
140
+ return lines;
141
+ }
142
+ function serializeFrontmatter(data, body) {
143
+ const trimmedBody = body.trimStart();
144
+ if (trimmedBody.startsWith("---")) {
145
+ const endIdx = trimmedBody.indexOf("---", 3);
146
+ if (endIdx > 0) {
147
+ const bodyFmContent = trimmedBody.slice(3, endIdx).trim();
148
+ const afterFm = trimmedBody.slice(endIdx + 3);
149
+ const lines = ["---"];
150
+ lines.push(...serializeYamlFields(data));
151
+ if (bodyFmContent) {
152
+ const modelKeys = new Set(Object.keys(data));
153
+ for (const line of bodyFmContent.split("\n")) {
154
+ if (line.length > 0 && line[0] !== " " && line[0] !== "-") {
155
+ const colonIdx = line.indexOf(":");
156
+ const lineKey = colonIdx > 0 ? line.slice(0, colonIdx).trim() : "";
157
+ if (lineKey && modelKeys.has(lineKey)) continue;
158
+ }
159
+ lines.push(line);
160
+ }
161
+ }
162
+ lines.push("---");
163
+ if (afterFm.trim()) lines.push(afterFm.trimStart());
164
+ lines.push("");
165
+ return lines.join("\n");
166
+ }
167
+ }
168
+ const lines = ["---"];
169
+ lines.push(...serializeYamlFields(data));
170
+ lines.push("---");
171
+ lines.push("");
172
+ if (body) {
173
+ lines.push(body);
174
+ lines.push("");
175
+ }
176
+ return lines.join("\n");
177
+ }
178
+ function resolveContentDir(projectRoot, model) {
179
+ if (model.content_path) return join(projectRoot, model.content_path);
180
+ return join(contentrainDir(projectRoot), "content", model.domain, model.id);
181
+ }
182
+ function resolveLocaleStrategy(model) {
183
+ return model.locale_strategy ?? "file";
184
+ }
185
+ /** Build the file path for a JSON content file (singleton/collection/dictionary) */
186
+ function resolveJsonFilePath(dir, model, locale) {
187
+ if (!model.i18n) return join(dir, "data.json");
188
+ switch (resolveLocaleStrategy(model)) {
189
+ case "suffix": return join(dir, `${model.id}.${locale}.json`);
190
+ case "directory": return join(dir, locale, `${model.id}.json`);
191
+ case "none": return join(dir, `${model.id}.json`);
192
+ default: return join(dir, `${locale}.json`);
193
+ }
194
+ }
195
+ /** Build the file path for a markdown document */
196
+ function resolveMdFilePath(dir, model, locale, slug) {
197
+ if (!model.i18n) return join(dir, `${slug}.md`);
198
+ switch (resolveLocaleStrategy(model)) {
199
+ case "suffix": return join(dir, `${slug}.${locale}.md`);
200
+ case "directory": return join(dir, locale, `${slug}.md`);
201
+ case "none": return join(dir, `${slug}.md`);
202
+ default: return join(dir, slug, `${locale}.md`);
203
+ }
204
+ }
205
+ function defaultMeta(data) {
206
+ const meta = {
207
+ status: "draft",
208
+ source: "agent",
209
+ updated_by: "contentrain-mcp"
210
+ };
211
+ if (data?.["publish_at"] !== void 0) meta.publish_at = data["publish_at"];
212
+ if (data?.["expire_at"] !== void 0) meta.expire_at = data["expire_at"];
213
+ return meta;
214
+ }
215
+ async function writeContent(projectRoot, model, entries, config) {
216
+ const results = [];
217
+ const defaultLocale = config.locales.default;
218
+ for (const entry of entries) {
219
+ const locale = entry.locale ?? defaultLocale;
220
+ const localeErr = validateLocale(locale, config);
221
+ if (localeErr) throw new Error(localeErr);
222
+ if (entry.id) {
223
+ const idErr = validateEntryId(entry.id);
224
+ if (idErr) throw new Error(idErr);
225
+ }
226
+ if (entry.slug) {
227
+ const slugErr = validateSlug(entry.slug);
228
+ if (slugErr) throw new Error(slugErr);
229
+ }
230
+ switch (model.kind) {
231
+ case "singleton":
232
+ await writeJson(resolveJsonFilePath(resolveContentDir(projectRoot, model), model, locale), entry.data);
233
+ await writeMeta(projectRoot, model, { locale }, defaultMeta(entry.data));
234
+ results.push({
235
+ action: "updated",
236
+ locale
237
+ });
238
+ break;
239
+ case "collection": {
240
+ const isNew = !entry.id;
241
+ const id = entry.id ?? generateEntryId();
242
+ const filePath = resolveJsonFilePath(resolveContentDir(projectRoot, model), model, locale);
243
+ const existing = await readJson(filePath) ?? {};
244
+ const action = isNew || !(id in existing) ? "created" : "updated";
245
+ existing[id] = entry.data;
246
+ const sorted = {};
247
+ for (const key of Object.keys(existing).toSorted()) sorted[key] = existing[key];
248
+ await writeJson(filePath, sorted);
249
+ await writeMeta(projectRoot, model, {
250
+ locale,
251
+ entryId: id
252
+ }, defaultMeta(entry.data));
253
+ results.push({
254
+ action,
255
+ id,
256
+ locale
257
+ });
258
+ break;
259
+ }
260
+ case "document": {
261
+ const slug = entry.slug ?? entry.data["slug"];
262
+ if (!slug) throw new Error("Document entries require a slug");
263
+ const slugErr = validateSlug(slug);
264
+ if (slugErr) throw new Error(slugErr);
265
+ const bodyContent = entry.data["body"] ?? "";
266
+ const fmData = { ...entry.data };
267
+ delete fmData["body"];
268
+ if (!fmData["slug"]) fmData["slug"] = slug;
269
+ const docPath = resolveMdFilePath(resolveContentDir(projectRoot, model), model, locale, slug);
270
+ const action = await readText(docPath) ? "updated" : "created";
271
+ await writeText(docPath, serializeFrontmatter(fmData, bodyContent));
272
+ await writeMeta(projectRoot, model, {
273
+ locale,
274
+ slug
275
+ }, defaultMeta(entry.data));
276
+ results.push({
277
+ action,
278
+ slug,
279
+ locale
280
+ });
281
+ break;
282
+ }
283
+ case "dictionary": {
284
+ const filePath = resolveJsonFilePath(resolveContentDir(projectRoot, model), model, locale);
285
+ const existing = await readJson(filePath) ?? {};
286
+ const collisions = [];
287
+ for (const key of Object.keys(entry.data)) if (key in existing && existing[key] !== entry.data[key]) collisions.push(key);
288
+ if (collisions.length > 0) throw new Error(`Dictionary "${model.id}" (${locale}): ${collisions.length} key collision(s) — [${collisions.join(", ")}] already exist with different values. Read existing keys with contentrain_content_list first, or include all keys in a single save call.`);
289
+ await writeJson(filePath, {
290
+ ...existing,
291
+ ...entry.data
292
+ });
293
+ await writeMeta(projectRoot, model, { locale }, defaultMeta(entry.data));
294
+ results.push({
295
+ action: "updated",
296
+ locale
297
+ });
298
+ break;
299
+ }
300
+ }
301
+ }
302
+ return results;
303
+ }
304
+ async function deleteContent(projectRoot, model, opts) {
305
+ const removed = [];
306
+ const cDir = resolveContentDir(projectRoot, model);
307
+ switch (model.kind) {
308
+ case "collection": {
309
+ if (!opts.id) throw new Error("Collection delete requires an entry ID");
310
+ const locales = opts.locale ? [opts.locale] : (await readDir(cDir)).filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", "").replace(`${model.id}.`, ""));
311
+ for (const loc of locales) {
312
+ const filePath = resolveJsonFilePath(cDir, model, loc);
313
+ const data = await readJson(filePath);
314
+ if (data && opts.id in data) {
315
+ delete data[opts.id];
316
+ await writeJson(filePath, data);
317
+ removed.push(`content/${model.domain}/${model.id}/${loc}.json#${opts.id}`);
318
+ }
319
+ }
320
+ for (const loc of locales) await deleteMeta(projectRoot, model, {
321
+ locale: loc,
322
+ entryId: opts.id
323
+ });
324
+ break;
325
+ }
326
+ case "document": {
327
+ if (!opts.slug) throw new Error("Document delete requires a slug");
328
+ const slugDelErr = validateSlug(opts.slug);
329
+ if (slugDelErr) throw new Error(slugDelErr);
330
+ const strategy = resolveLocaleStrategy(model);
331
+ if (!model.i18n) await rm(join(cDir, `${opts.slug}.md`), { force: true });
332
+ else if (strategy === "file") await rm(join(cDir, opts.slug), {
333
+ recursive: true,
334
+ force: true
335
+ });
336
+ else if (opts.locale) await rm(resolveMdFilePath(cDir, model, opts.locale, opts.slug), { force: true });
337
+ else {
338
+ const files = await readDir(strategy === "directory" ? cDir : cDir);
339
+ for (const f of files) if (strategy === "suffix" && f.startsWith(`${opts.slug}.`) && f.endsWith(".md")) await rm(join(cDir, f), { force: true });
340
+ else if (strategy === "directory") await rm(join(cDir, f, `${opts.slug}.md`), { force: true });
341
+ else if (strategy === "none") {
342
+ if (f === `${opts.slug}.md`) await rm(join(cDir, f), { force: true });
343
+ }
344
+ }
345
+ removed.push(`${model.content_path ?? `content/${model.domain}/${model.id}`}/${opts.slug}`);
346
+ await deleteMeta(projectRoot, model, {
347
+ slug: opts.slug,
348
+ locale: opts.locale
349
+ });
350
+ break;
351
+ }
352
+ case "singleton": {
353
+ if (model.i18n && !opts.locale) throw new Error("Singleton delete requires a locale when i18n is enabled");
354
+ const locale = opts.locale ?? "data";
355
+ await rm(resolveJsonFilePath(cDir, model, locale), { force: true });
356
+ removed.push(model.i18n ? `content/${model.domain}/${model.id}/${locale}.json` : `content/${model.domain}/${model.id}/data.json`);
357
+ await deleteMeta(projectRoot, model, { locale: model.i18n ? locale : void 0 });
358
+ break;
359
+ }
360
+ case "dictionary": {
361
+ if (model.i18n && !opts.locale) throw new Error("Dictionary delete requires a locale when i18n is enabled");
362
+ const locale = opts.locale ?? "data";
363
+ const filePath = resolveJsonFilePath(cDir, model, locale);
364
+ if (opts.keys?.length) {
365
+ const existing = await readJson(filePath) ?? {};
366
+ const notFound = [];
367
+ for (const key of opts.keys) if (key in existing) delete existing[key];
368
+ else notFound.push(key);
369
+ if (notFound.length > 0) throw new Error(`Dictionary "${model.id}" (${locale}): keys not found — [${notFound.join(", ")}]`);
370
+ await writeJson(filePath, existing);
371
+ removed.push(...opts.keys.map((k) => `${model.id}/${locale}:${k}`));
372
+ } else {
373
+ await rm(filePath, { force: true });
374
+ removed.push(model.i18n ? `content/${model.domain}/${model.id}/${locale}.json` : `content/${model.domain}/${model.id}/data.json`);
375
+ await deleteMeta(projectRoot, model, { locale: model.i18n ? locale : void 0 });
376
+ }
377
+ break;
378
+ }
379
+ }
380
+ return removed;
381
+ }
382
+ async function listContent(projectRoot, model, opts, config) {
383
+ const cDir = resolveContentDir(projectRoot, model);
384
+ const locale = opts.locale ?? config.locales.default;
385
+ switch (model.kind) {
386
+ case "singleton": return {
387
+ kind: "singleton",
388
+ data: await readJson(resolveJsonFilePath(cDir, model, locale)) ?? {},
389
+ locale
390
+ };
391
+ case "collection": {
392
+ const data = await readJson(resolveJsonFilePath(cDir, model, locale)) ?? {};
393
+ let entries = Object.entries(data).map(([id, fields]) => {
394
+ const entry = { id };
395
+ Object.assign(entry, fields);
396
+ return entry;
397
+ });
398
+ if (opts.filter) entries = entries.filter((entry) => {
399
+ for (const [key, value] of Object.entries(opts.filter)) if (entry[key] !== value) return false;
400
+ return true;
401
+ });
402
+ const total = entries.length;
403
+ const offset = opts.offset ?? 0;
404
+ const limit = opts.limit ?? entries.length;
405
+ entries = entries.slice(offset, offset + limit);
406
+ if (opts.resolve && model.fields) entries = await resolveRelations(projectRoot, model, entries, locale);
407
+ return {
408
+ kind: "collection",
409
+ data: entries,
410
+ total,
411
+ locale,
412
+ offset,
413
+ limit
414
+ };
415
+ }
416
+ case "document": {
417
+ const entries = [];
418
+ const strategy = resolveLocaleStrategy(model);
419
+ if (!model.i18n) {
420
+ const files = await readDir(cDir);
421
+ for (const f of files) {
422
+ if (!f.endsWith(".md")) continue;
423
+ const slug = f.replace(".md", "");
424
+ const raw = await readText(join(cDir, f));
425
+ if (!raw) continue;
426
+ const { frontmatter, body } = parseFrontmatter(raw);
427
+ entries.push({
428
+ slug,
429
+ frontmatter,
430
+ body
431
+ });
432
+ }
433
+ } else if (strategy === "file") {
434
+ const slugDirs = await readDir(cDir);
435
+ for (const slug of slugDirs) {
436
+ const raw = await readText(join(cDir, slug, `${locale}.md`));
437
+ if (!raw) continue;
438
+ const { frontmatter, body } = parseFrontmatter(raw);
439
+ entries.push({
440
+ slug,
441
+ frontmatter,
442
+ body
443
+ });
444
+ }
445
+ } else if (strategy === "suffix") {
446
+ const files = await readDir(cDir);
447
+ const suffix = `.${locale}.md`;
448
+ for (const f of files) {
449
+ if (!f.endsWith(suffix)) continue;
450
+ const slug = f.slice(0, -suffix.length);
451
+ const raw = await readText(join(cDir, f));
452
+ if (!raw) continue;
453
+ const { frontmatter, body } = parseFrontmatter(raw);
454
+ entries.push({
455
+ slug,
456
+ frontmatter,
457
+ body
458
+ });
459
+ }
460
+ } else if (strategy === "directory") {
461
+ const localeDir = join(cDir, locale);
462
+ const files = await readDir(localeDir);
463
+ for (const f of files) {
464
+ if (!f.endsWith(".md")) continue;
465
+ const slug = f.replace(".md", "");
466
+ const raw = await readText(join(localeDir, f));
467
+ if (!raw) continue;
468
+ const { frontmatter, body } = parseFrontmatter(raw);
469
+ entries.push({
470
+ slug,
471
+ frontmatter,
472
+ body
473
+ });
474
+ }
475
+ } else {
476
+ const files = await readDir(cDir);
477
+ for (const f of files) {
478
+ if (!f.endsWith(".md")) continue;
479
+ const slug = f.replace(".md", "");
480
+ const raw = await readText(join(cDir, f));
481
+ if (!raw) continue;
482
+ const { frontmatter, body } = parseFrontmatter(raw);
483
+ entries.push({
484
+ slug,
485
+ frontmatter,
486
+ body
487
+ });
488
+ }
489
+ }
490
+ const total = entries.length;
491
+ const offset = opts.offset ?? 0;
492
+ const limit = opts.limit ?? entries.length;
493
+ return {
494
+ kind: "document",
495
+ data: entries.slice(offset, offset + limit),
496
+ total,
497
+ locale,
498
+ offset,
499
+ limit
500
+ };
501
+ }
502
+ case "dictionary": {
503
+ const data = await readJson(resolveJsonFilePath(cDir, model, locale)) ?? {};
504
+ return {
505
+ kind: "dictionary",
506
+ data,
507
+ total_keys: Object.keys(data).length,
508
+ locale
509
+ };
510
+ }
511
+ }
512
+ }
513
+ async function readContent(projectRoot, model, opts) {
514
+ const cDir = resolveContentDir(projectRoot, model);
515
+ switch (model.kind) {
516
+ case "singleton": return readJson(resolveJsonFilePath(cDir, model, opts.locale));
517
+ case "collection": {
518
+ if (!opts.entryId) return null;
519
+ const data = await readJson(resolveJsonFilePath(cDir, model, opts.locale));
520
+ return data?.[opts.entryId] ? {
521
+ id: opts.entryId,
522
+ ...data[opts.entryId]
523
+ } : null;
524
+ }
525
+ case "document": {
526
+ if (!opts.slug) return null;
527
+ const raw = await readText(resolveMdFilePath(cDir, model, opts.locale, opts.slug));
528
+ if (!raw) return null;
529
+ const { frontmatter, body } = parseFrontmatter(raw);
530
+ return {
531
+ slug: opts.slug,
532
+ ...frontmatter,
533
+ body
534
+ };
535
+ }
536
+ case "dictionary": return readJson(resolveJsonFilePath(cDir, model, opts.locale));
537
+ }
538
+ }
539
+ async function resolveRelations(projectRoot, model, entries, locale) {
540
+ if (!model.fields) return entries;
541
+ const relationFields = [];
542
+ for (const [name, field] of Object.entries(model.fields)) if (field.type === "relation" || field.type === "relations") {
543
+ const targets = Array.isArray(field.model) ? field.model : field.model ? [field.model] : [];
544
+ relationFields.push({
545
+ name,
546
+ targetModels: targets,
547
+ multi: field.type === "relations"
548
+ });
549
+ }
550
+ if (relationFields.length === 0) return entries;
551
+ const targetCache = {};
552
+ const visited = new Set([model.id]);
553
+ for (const rf of relationFields) for (const targetModelId of rf.targetModels) {
554
+ if (targetCache[targetModelId] || visited.has(targetModelId)) continue;
555
+ visited.add(targetModelId);
556
+ const targetModel = await readModel(projectRoot, targetModelId);
557
+ if (!targetModel) continue;
558
+ if (targetModel.kind === "collection") targetCache[targetModelId] = await readJson(resolveJsonFilePath(resolveContentDir(projectRoot, targetModel), targetModel, locale)) ?? {};
559
+ else if (targetModel.kind === "document") {
560
+ const docCache = {};
561
+ const cDir = resolveContentDir(projectRoot, targetModel);
562
+ const strategy = resolveLocaleStrategy(targetModel);
563
+ if (!targetModel.i18n) {
564
+ const files = await readDir(cDir);
565
+ for (const f of files) {
566
+ if (!f.endsWith(".md")) continue;
567
+ const slug = f.replace(".md", "");
568
+ const raw = await readText(join(cDir, f));
569
+ if (!raw) continue;
570
+ const { frontmatter, body } = parseFrontmatter(raw);
571
+ docCache[slug] = {
572
+ slug,
573
+ ...frontmatter,
574
+ body
575
+ };
576
+ }
577
+ } else if (strategy === "file") {
578
+ const slugDirs = await readDir(cDir);
579
+ for (const slug of slugDirs) {
580
+ const raw = await readText(join(cDir, slug, `${locale}.md`));
581
+ if (!raw) continue;
582
+ const { frontmatter, body } = parseFrontmatter(raw);
583
+ docCache[slug] = {
584
+ slug,
585
+ ...frontmatter,
586
+ body
587
+ };
588
+ }
589
+ } else if (strategy === "suffix") {
590
+ const files = await readDir(cDir);
591
+ const suffix = `.${locale}.md`;
592
+ for (const f of files) {
593
+ if (!f.endsWith(suffix)) continue;
594
+ const slug = f.slice(0, -suffix.length);
595
+ const raw = await readText(join(cDir, f));
596
+ if (!raw) continue;
597
+ const { frontmatter, body } = parseFrontmatter(raw);
598
+ docCache[slug] = {
599
+ slug,
600
+ ...frontmatter,
601
+ body
602
+ };
603
+ }
604
+ } else if (strategy === "directory") {
605
+ const localeDir = join(cDir, locale);
606
+ const files = await readDir(localeDir);
607
+ for (const f of files) {
608
+ if (!f.endsWith(".md")) continue;
609
+ const slug = f.replace(".md", "");
610
+ const raw = await readText(join(localeDir, f));
611
+ if (!raw) continue;
612
+ const { frontmatter, body } = parseFrontmatter(raw);
613
+ docCache[slug] = {
614
+ slug,
615
+ ...frontmatter,
616
+ body
617
+ };
618
+ }
619
+ } else {
620
+ const files = await readDir(cDir);
621
+ for (const f of files) {
622
+ if (!f.endsWith(".md")) continue;
623
+ const slug = f.replace(".md", "");
624
+ const raw = await readText(join(cDir, f));
625
+ if (!raw) continue;
626
+ const { frontmatter, body } = parseFrontmatter(raw);
627
+ docCache[slug] = {
628
+ slug,
629
+ ...frontmatter,
630
+ body
631
+ };
632
+ }
633
+ }
634
+ targetCache[targetModelId] = docCache;
635
+ }
636
+ }
637
+ return entries.map((entry) => {
638
+ const resolved = { ...entry };
639
+ for (const rf of relationFields) {
640
+ const value = resolved[rf.name];
641
+ if (!value) continue;
642
+ if (rf.multi && Array.isArray(value)) resolved[rf.name] = value.map((id) => {
643
+ for (const targetModelId of rf.targetModels) {
644
+ const cached = targetCache[targetModelId]?.[id];
645
+ if (cached) return {
646
+ id,
647
+ ...cached
648
+ };
649
+ }
650
+ return id;
651
+ });
652
+ else if (typeof value === "string") for (const targetModelId of rf.targetModels) {
653
+ const cached = targetCache[targetModelId]?.[value];
654
+ if (cached) {
655
+ resolved[rf.name] = {
656
+ id: value,
657
+ ...cached
658
+ };
659
+ break;
660
+ }
661
+ }
662
+ }
663
+ return resolved;
664
+ });
665
+ }
666
+ //#endregion
667
+ //#region src/core/model-manager.ts
668
+ async function listModels(projectRoot) {
669
+ const modelsDir = join(contentrainDir(projectRoot), "models");
670
+ const jsonFiles = (await readDir(modelsDir)).filter((f) => f.endsWith(".json"));
671
+ return (await Promise.all(jsonFiles.map((file) => readJson(join(modelsDir, file))))).filter((m) => m !== null && !!m.id).map((model) => ({
672
+ id: model.id,
673
+ kind: model.kind,
674
+ domain: model.domain,
675
+ i18n: model.i18n,
676
+ fields: model.fields ? Object.keys(model.fields).length : 0
677
+ })).toSorted((a, b) => a.id.localeCompare(b.id, "en"));
678
+ }
679
+ async function readModel(projectRoot, modelId) {
680
+ return readJson(join(contentrainDir(projectRoot), "models", `${modelId}.json`));
681
+ }
682
+ async function countDocumentFileStrategy(contentDir, entries) {
683
+ const locales = {};
684
+ let total = 0;
685
+ const results = await Promise.all(entries.map(async (entry) => {
686
+ const localeFiles = await readDir(join(contentDir, entry));
687
+ return localeFiles.map((lf) => lf.replace(/\.(json|md|mdx)$/, "")).filter((locale, i) => locale !== localeFiles[i]);
688
+ }));
689
+ for (const entryLocales of results) for (const locale of entryLocales) {
690
+ locales[locale] = (locales[locale] ?? 0) + 1;
691
+ total++;
692
+ }
693
+ return {
694
+ total,
695
+ locales
696
+ };
697
+ }
698
+ async function countDocumentSuffixStrategy(contentDir, files) {
699
+ const locales = {};
700
+ const slugsByLocale = {};
701
+ for (const f of files) {
702
+ if (!f.endsWith(".md")) continue;
703
+ const match = f.match(/^(.+)\.([a-z]{2}(?:-[A-Z]{2})?)\.md$/);
704
+ if (!match) continue;
705
+ const locale = match[2];
706
+ if (!slugsByLocale[locale]) slugsByLocale[locale] = /* @__PURE__ */ new Set();
707
+ slugsByLocale[locale].add(match[1]);
708
+ }
709
+ let total = 0;
710
+ for (const [locale, slugs] of Object.entries(slugsByLocale)) {
711
+ locales[locale] = slugs.size;
712
+ total += slugs.size;
713
+ }
714
+ return {
715
+ total,
716
+ locales
717
+ };
718
+ }
719
+ async function countDocumentDirectoryStrategy(contentDir, localeDirs) {
720
+ const locales = {};
721
+ let total = 0;
722
+ const results = await Promise.all(localeDirs.map(async (localeDir) => {
723
+ return {
724
+ locale: localeDir,
725
+ count: (await readDir(join(contentDir, localeDir))).filter((f) => f.endsWith(".md")).length
726
+ };
727
+ }));
728
+ for (const { locale, count } of results) {
729
+ locales[locale] = count;
730
+ total += count;
731
+ }
732
+ return {
733
+ total,
734
+ locales
735
+ };
736
+ }
737
+ async function countDocumentNoneStrategy(projectRoot, modelId, files, i18n) {
738
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
739
+ if (!i18n) return {
740
+ total: mdFiles.length,
741
+ locales: { _: mdFiles.length }
742
+ };
743
+ const metaDir = join(contentrainDir(projectRoot), "meta", modelId);
744
+ const locales = {};
745
+ let total = 0;
746
+ const slugs = mdFiles.map((f) => f.replace(".md", ""));
747
+ const results = await Promise.all(slugs.map(async (slug) => {
748
+ return (await readDir(join(metaDir, slug))).filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", ""));
749
+ }));
750
+ for (const slugLocales of results) for (const locale of slugLocales) {
751
+ locales[locale] = (locales[locale] ?? 0) + 1;
752
+ total++;
753
+ }
754
+ return {
755
+ total,
756
+ locales
757
+ };
758
+ }
759
+ async function countCollectionEntries(contentDir, jsonFiles) {
760
+ const locales = {};
761
+ let total = 0;
762
+ const results = await Promise.all(jsonFiles.map(async (file) => {
763
+ const locale = file.replace(/\.json$/, "");
764
+ const data = await readJson(join(contentDir, file));
765
+ return {
766
+ locale,
767
+ count: data ? Object.keys(data).length : 0
768
+ };
769
+ }));
770
+ for (const { locale, count } of results) {
771
+ locales[locale] = count;
772
+ total += count;
773
+ }
774
+ return {
775
+ total,
776
+ locales
777
+ };
778
+ }
779
+ async function countEntries(projectRoot, model) {
780
+ const cDir = resolveContentDir(projectRoot, model);
781
+ const strategy = resolveLocaleStrategy(model);
782
+ const files = await readDir(cDir);
783
+ if (model.kind === "document") {
784
+ if (!model.i18n) return countDocumentNoneStrategy(projectRoot, model.id, files, false);
785
+ switch (strategy) {
786
+ case "file": return countDocumentFileStrategy(cDir, files);
787
+ case "suffix": return countDocumentSuffixStrategy(cDir, files);
788
+ case "directory": return countDocumentDirectoryStrategy(cDir, files);
789
+ case "none": return countDocumentNoneStrategy(projectRoot, model.id, files, true);
790
+ }
791
+ }
792
+ if (model.kind === "collection") {
793
+ if (!model.i18n) return countCollectionEntries(cDir, files.filter((f) => f.endsWith(".json")));
794
+ switch (strategy) {
795
+ case "suffix": {
796
+ const jsonFiles = files.filter((f) => f.endsWith(".json"));
797
+ const locales = {};
798
+ for (const f of jsonFiles) {
799
+ const match = f.match(/^.+\.([a-z]{2}(?:-[A-Z]{2})?)\.json$/);
800
+ if (match) {
801
+ const data = await readJson(join(cDir, f));
802
+ locales[match[1]] = data ? Object.keys(data).length : 0;
803
+ }
804
+ }
805
+ return {
806
+ total: Object.values(locales).reduce((a, b) => a + b, 0),
807
+ locales
808
+ };
809
+ }
810
+ case "directory": {
811
+ const locales = {};
812
+ let total = 0;
813
+ for (const localeDir of files) {
814
+ const jsonFile = (await readDir(join(cDir, localeDir))).find((f) => f.endsWith(".json"));
815
+ if (jsonFile) {
816
+ const data = await readJson(join(cDir, localeDir, jsonFile));
817
+ const count = data ? Object.keys(data).length : 0;
818
+ locales[localeDir] = count;
819
+ total += count;
820
+ }
821
+ }
822
+ return {
823
+ total,
824
+ locales
825
+ };
826
+ }
827
+ case "none": {
828
+ const noneFile = files.find((f) => f === `${model.id}.json`);
829
+ if (!noneFile) return {
830
+ total: 0,
831
+ locales: {}
832
+ };
833
+ const data = await readJson(join(cDir, noneFile));
834
+ const count = data ? Object.keys(data).length : 0;
835
+ return {
836
+ total: count,
837
+ locales: { _: count }
838
+ };
839
+ }
840
+ default: return countCollectionEntries(cDir, files.filter((f) => f.endsWith(".json")));
841
+ }
842
+ }
843
+ if (!model.i18n) return {
844
+ total: files.some((f) => f === "data.json") ? 1 : 0,
845
+ locales: {}
846
+ };
847
+ switch (strategy) {
848
+ case "suffix": {
849
+ const locales = {};
850
+ for (const f of files) {
851
+ const match = f.match(/^.+\.([a-z]{2}(?:-[A-Z]{2})?)\.json$/);
852
+ if (match) locales[match[1]] = 1;
853
+ }
854
+ return {
855
+ total: Object.keys(locales).length,
856
+ locales
857
+ };
858
+ }
859
+ case "directory": {
860
+ const locales = {};
861
+ for (const localeDir of files) if ((await readDir(join(cDir, localeDir))).some((f) => f.endsWith(".json"))) locales[localeDir] = 1;
862
+ return {
863
+ total: Object.keys(locales).length,
864
+ locales
865
+ };
866
+ }
867
+ case "none": return {
868
+ total: files.some((f) => f === `${model.id}.json`) ? 1 : 0,
869
+ locales: {}
870
+ };
871
+ default: {
872
+ const jsonFiles = files.filter((f) => f.endsWith(".json"));
873
+ const locales = {};
874
+ for (const file of jsonFiles) locales[file.replace(/\.json$/, "")] = 1;
875
+ return {
876
+ total: jsonFiles.length,
877
+ locales
878
+ };
879
+ }
880
+ }
881
+ }
882
+ const MODEL_FIELD_ORDER = [
883
+ "id",
884
+ "name",
885
+ "kind",
886
+ "domain",
887
+ "i18n",
888
+ "description",
889
+ "content_path",
890
+ "locale_strategy",
891
+ "fields"
892
+ ];
893
+ async function writeModel(projectRoot, model) {
894
+ await writeJson(join(contentrainDir(projectRoot), "models", `${model.id}.json`), model, MODEL_FIELD_ORDER);
895
+ await ensureDir(resolveContentDir(projectRoot, model));
896
+ await ensureDir(join(contentrainDir(projectRoot), "meta", model.id));
897
+ }
898
+ async function deleteModel(projectRoot, modelId) {
899
+ const model = await readModel(projectRoot, modelId);
900
+ if (!model) return [];
901
+ const crDir = contentrainDir(projectRoot);
902
+ const removed = [];
903
+ const modelPath = join(crDir, "models", `${modelId}.json`);
904
+ const contentPath = resolveContentDir(projectRoot, model);
905
+ const metaPath = join(crDir, "meta", modelId);
906
+ await rm(modelPath, { force: true });
907
+ removed.push(`models/${modelId}.json`);
908
+ try {
909
+ await rm(contentPath, {
910
+ recursive: true,
911
+ force: true
912
+ });
913
+ removed.push(model.content_path ?? `content/${model.domain}/${modelId}/`);
914
+ } catch {}
915
+ try {
916
+ await rm(metaPath, {
917
+ recursive: true,
918
+ force: true
919
+ });
920
+ removed.push(`meta/${modelId}/`);
921
+ } catch {}
922
+ return removed;
923
+ }
924
+ async function checkReferences(projectRoot, modelId) {
925
+ const others = (await listModels(projectRoot)).filter((s) => s.id !== modelId);
926
+ const models = await Promise.all(others.map((s) => readModel(projectRoot, s.id)));
927
+ const refs = [];
928
+ for (const model of models) {
929
+ if (!model?.fields) continue;
930
+ for (const [fieldName, fieldDef] of Object.entries(model.fields)) {
931
+ if (fieldDef.type !== "relation" && fieldDef.type !== "relations") continue;
932
+ if ((Array.isArray(fieldDef.model) ? fieldDef.model : [fieldDef.model]).includes(modelId)) refs.push({
933
+ model: model.id,
934
+ field: fieldName,
935
+ type: fieldDef.type
936
+ });
937
+ }
938
+ }
939
+ return refs;
940
+ }
941
+ const FIELD_TYPE_ENUM = [
942
+ "string",
943
+ "text",
944
+ "email",
945
+ "url",
946
+ "slug",
947
+ "color",
948
+ "phone",
949
+ "code",
950
+ "icon",
951
+ "markdown",
952
+ "richtext",
953
+ "number",
954
+ "integer",
955
+ "decimal",
956
+ "percent",
957
+ "rating",
958
+ "boolean",
959
+ "date",
960
+ "datetime",
961
+ "image",
962
+ "video",
963
+ "file",
964
+ "relation",
965
+ "relations",
966
+ "select",
967
+ "array",
968
+ "object"
969
+ ];
970
+ /**
971
+ * Shared Zod schema for field definitions.
972
+ * Used by both model_save and normalize extract for full parity.
973
+ */
974
+ const fieldDefZodSchema = z.record(z.string(), z.object({
975
+ type: z.enum(FIELD_TYPE_ENUM).describe("Field type from the 27-type catalog"),
976
+ required: z.boolean().optional(),
977
+ unique: z.boolean().optional(),
978
+ default: z.unknown().optional(),
979
+ min: z.number().optional(),
980
+ max: z.number().optional(),
981
+ pattern: z.string().optional(),
982
+ options: z.array(z.string()).optional(),
983
+ model: z.union([z.string(), z.array(z.string())]).optional(),
984
+ items: z.union([z.string(), z.lazy(() => z.record(z.string(), z.unknown()))]).optional(),
985
+ fields: z.lazy(() => z.record(z.string(), z.unknown())).optional(),
986
+ accept: z.string().optional(),
987
+ maxSize: z.number().optional(),
988
+ description: z.string().optional()
989
+ }).refine((f) => {
990
+ if ((f.type === "relation" || f.type === "relations") && !f.model) return false;
991
+ if (f.type === "select" && (!f.options || f.options.length === 0)) return false;
992
+ return true;
993
+ }, { message: "relation/relations requires \"model\", select requires non-empty \"options\"" }));
994
+ const VALID_FIELD_TYPES = new Set(FIELD_TYPE_ENUM);
995
+ /**
996
+ * Validate a model definition before writing.
997
+ * Returns array of error messages (empty = valid).
998
+ * Used by both model_save tool and normalize extract.
999
+ */
1000
+ function validateModelDefinition(input) {
1001
+ const errors = [];
1002
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.id)) errors.push(`Invalid model ID "${input.id}": must be kebab-case`);
1003
+ if (input.kind === "dictionary" && input.fields && Object.keys(input.fields).length > 0) errors.push("Dictionary models cannot have fields. Dictionaries store flat key-value pairs.");
1004
+ if (input.fields) for (const [fieldName, fieldDef] of Object.entries(input.fields)) {
1005
+ const def = fieldDef;
1006
+ if (!/^[a-z][a-z0-9_]*$/.test(fieldName)) errors.push(`Field "${fieldName}": invalid name — must be snake_case starting with letter`);
1007
+ if (!def.type || !VALID_FIELD_TYPES.has(def.type)) errors.push(`Field "${fieldName}": invalid type "${def.type}"`);
1008
+ if ((def.type === "relation" || def.type === "relations") && !def.model) errors.push(`Field "${fieldName}": ${def.type} type requires "model" property`);
1009
+ if (def.type === "select" && (!def.options || !Array.isArray(def.options) || def.options.length === 0)) errors.push(`Field "${fieldName}": select type requires non-empty "options" array`);
1010
+ }
1011
+ return errors;
1012
+ }
1013
+ //#endregion
1014
+ export { writeContent as S, resolveMdFilePath as _, fieldDefZodSchema as a, validateLocale as b, validateModelDefinition as c, listContent as d, parseFrontmatter as f, resolveLocaleStrategy as g, resolveJsonFilePath as h, deleteModel as i, writeModel as l, resolveContentDir as m, checkReferences as n, listModels as o, readContent as p, countEntries as r, readModel as s, FIELD_TYPE_ENUM as t, deleteContent as u, serializeFrontmatter as v, validateSlug as x, validateEntryId as y };
1015
+
1016
+ //# sourceMappingURL=model-manager-sfPdrVIJ.mjs.map