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