@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,755 @@
|
|
|
1
|
+
import { a as readJson, c as writeText, i as readDir, o as readText, r as pathExists, s as writeJson, t as contentrainDir } from "./fs-BbuGnVcG.mjs";
|
|
2
|
+
import { t as readConfig } from "./config-CPlhDwsv.mjs";
|
|
3
|
+
import { r as writeMeta } from "./meta-manager-CECrfsKm.mjs";
|
|
4
|
+
import { _ as resolveMdFilePath, f as parseFrontmatter, g as resolveLocaleStrategy, h as resolveJsonFilePath, m as resolveContentDir, o as listModels, s as readModel } from "./model-manager-BcWWu5l2.mjs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { rm } from "node:fs/promises";
|
|
7
|
+
//#region src/core/validator.ts
|
|
8
|
+
const SECRET_PATTERNS = [
|
|
9
|
+
/sk_/,
|
|
10
|
+
/pk_/,
|
|
11
|
+
/api_key/i,
|
|
12
|
+
/apikey/i,
|
|
13
|
+
/ghp_/,
|
|
14
|
+
/gho_/,
|
|
15
|
+
/Bearer /,
|
|
16
|
+
/postgres:\/\//,
|
|
17
|
+
/mysql:\/\//,
|
|
18
|
+
/mongodb:\/\//,
|
|
19
|
+
/-----BEGIN/,
|
|
20
|
+
/AKIA/,
|
|
21
|
+
/aws_secret/i
|
|
22
|
+
];
|
|
23
|
+
function checkSecret(value) {
|
|
24
|
+
if (typeof value !== "string") return false;
|
|
25
|
+
return SECRET_PATTERNS.some((p) => p.test(value));
|
|
26
|
+
}
|
|
27
|
+
function fieldTypeMatches(value, fieldDef) {
|
|
28
|
+
if (value === null || value === void 0) return true;
|
|
29
|
+
switch (fieldDef.type) {
|
|
30
|
+
case "string":
|
|
31
|
+
case "text":
|
|
32
|
+
case "email":
|
|
33
|
+
case "url":
|
|
34
|
+
case "slug":
|
|
35
|
+
case "color":
|
|
36
|
+
case "phone":
|
|
37
|
+
case "code":
|
|
38
|
+
case "icon":
|
|
39
|
+
case "markdown":
|
|
40
|
+
case "richtext":
|
|
41
|
+
case "date":
|
|
42
|
+
case "datetime":
|
|
43
|
+
case "image":
|
|
44
|
+
case "video":
|
|
45
|
+
case "file":
|
|
46
|
+
case "select": return typeof value === "string";
|
|
47
|
+
case "number":
|
|
48
|
+
case "integer":
|
|
49
|
+
case "decimal":
|
|
50
|
+
case "percent":
|
|
51
|
+
case "rating": return typeof value === "number";
|
|
52
|
+
case "boolean": return typeof value === "boolean";
|
|
53
|
+
case "relation": return typeof value === "string";
|
|
54
|
+
case "relations":
|
|
55
|
+
case "array": return Array.isArray(value);
|
|
56
|
+
case "object": return typeof value === "object" && !Array.isArray(value);
|
|
57
|
+
default: return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function checkMinMax(value, fieldDef) {
|
|
61
|
+
if (fieldDef.min !== void 0) {
|
|
62
|
+
if (typeof value === "number" && value < fieldDef.min) return `Value ${value} is below minimum ${fieldDef.min}`;
|
|
63
|
+
if (typeof value === "string" && value.length < fieldDef.min) return `String length ${value.length} is below minimum ${fieldDef.min}`;
|
|
64
|
+
if (Array.isArray(value) && value.length < fieldDef.min) return `Array length ${value.length} is below minimum ${fieldDef.min}`;
|
|
65
|
+
}
|
|
66
|
+
if (fieldDef.max !== void 0) {
|
|
67
|
+
if (typeof value === "number" && value > fieldDef.max) return `Value ${value} is above maximum ${fieldDef.max}`;
|
|
68
|
+
if (typeof value === "string" && value.length > fieldDef.max) return `String length ${value.length} is above maximum ${fieldDef.max}`;
|
|
69
|
+
if (Array.isArray(value) && value.length > fieldDef.max) return `Array length ${value.length} is above maximum ${fieldDef.max}`;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function validateScheduleFields(meta, ctx, issues) {
|
|
74
|
+
if (meta.publish_at !== void 0) {
|
|
75
|
+
const d = new Date(meta.publish_at);
|
|
76
|
+
if (Number.isNaN(d.getTime())) issues.push({
|
|
77
|
+
severity: "error",
|
|
78
|
+
...ctx,
|
|
79
|
+
message: `Invalid publish_at date: "${meta.publish_at}". Must be a valid ISO 8601 date string.`
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (meta.expire_at !== void 0) {
|
|
83
|
+
const d = new Date(meta.expire_at);
|
|
84
|
+
if (Number.isNaN(d.getTime())) issues.push({
|
|
85
|
+
severity: "error",
|
|
86
|
+
...ctx,
|
|
87
|
+
message: `Invalid expire_at date: "${meta.expire_at}". Must be a valid ISO 8601 date string.`
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (meta.publish_at !== void 0 && meta.expire_at !== void 0) {
|
|
91
|
+
const pubDate = new Date(meta.publish_at);
|
|
92
|
+
const expDate = new Date(meta.expire_at);
|
|
93
|
+
if (!Number.isNaN(pubDate.getTime()) && !Number.isNaN(expDate.getTime()) && expDate <= pubDate) issues.push({
|
|
94
|
+
severity: "error",
|
|
95
|
+
...ctx,
|
|
96
|
+
message: `expire_at ("${meta.expire_at}") must be after publish_at ("${meta.publish_at}").`
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function checkRelation(projectRoot, fieldName, fieldDef, value, locale, ctx, issues) {
|
|
101
|
+
if (fieldDef.type !== "relation" && fieldDef.type !== "relations" || value === void 0 || value === null) return;
|
|
102
|
+
const targets = Array.isArray(fieldDef.model) ? fieldDef.model : fieldDef.model ? [fieldDef.model] : [];
|
|
103
|
+
const refs = fieldDef.type === "relations" && Array.isArray(value) ? value : [value];
|
|
104
|
+
for (const ref of refs) {
|
|
105
|
+
if (typeof ref !== "string") continue;
|
|
106
|
+
let found = false;
|
|
107
|
+
for (const targetModelId of targets) {
|
|
108
|
+
const targetModel = await readModel(projectRoot, targetModelId);
|
|
109
|
+
if (!targetModel) {
|
|
110
|
+
issues.push({
|
|
111
|
+
severity: "error",
|
|
112
|
+
...ctx,
|
|
113
|
+
field: fieldName,
|
|
114
|
+
message: `Broken relation: target model "${targetModelId}" not found`
|
|
115
|
+
});
|
|
116
|
+
found = true;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
if (targetModel.kind === "document") {
|
|
120
|
+
if ((await discoverDocumentSlugs(resolveContentDir(projectRoot, targetModel), targetModel)).includes(ref)) {
|
|
121
|
+
found = true;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
} else if (targetModel.kind === "singleton" || targetModel.kind === "dictionary") {
|
|
125
|
+
found = true;
|
|
126
|
+
break;
|
|
127
|
+
} else {
|
|
128
|
+
const targetData = await readJson(resolveJsonFilePath(resolveContentDir(projectRoot, targetModel), targetModel, locale));
|
|
129
|
+
if (targetData && ref in targetData) {
|
|
130
|
+
found = true;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!found) issues.push({
|
|
136
|
+
severity: "error",
|
|
137
|
+
...ctx,
|
|
138
|
+
field: fieldName,
|
|
139
|
+
message: `Broken relation: referenced ${targets.some((t) => t) ? "ref" : "ID"} "${ref}" not found in target model(s)`
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function validateCollectionModel(projectRoot, model, config, issues, fix) {
|
|
144
|
+
let entriesChecked = 0;
|
|
145
|
+
let fixed = 0;
|
|
146
|
+
const cDir = resolveContentDir(projectRoot, model);
|
|
147
|
+
const metaDir = join(contentrainDir(projectRoot), "meta", model.id);
|
|
148
|
+
const locales = config.locales.supported;
|
|
149
|
+
const localeEntryIds = {};
|
|
150
|
+
const allEntryIds = /* @__PURE__ */ new Set();
|
|
151
|
+
const uniqueFieldValues = {};
|
|
152
|
+
for (const locale of locales) {
|
|
153
|
+
const filePath = resolveJsonFilePath(cDir, model, locale);
|
|
154
|
+
const data = await readJson(filePath);
|
|
155
|
+
if (!data) {
|
|
156
|
+
if (model.i18n) {
|
|
157
|
+
issues.push({
|
|
158
|
+
severity: "error",
|
|
159
|
+
model: model.id,
|
|
160
|
+
locale,
|
|
161
|
+
message: `Locale file missing: ${locale}.json`
|
|
162
|
+
});
|
|
163
|
+
if (fix) {
|
|
164
|
+
await writeJson(filePath, {});
|
|
165
|
+
fixed++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const ids = new Set(Object.keys(data));
|
|
171
|
+
localeEntryIds[locale] = ids;
|
|
172
|
+
for (const id of ids) allEntryIds.add(id);
|
|
173
|
+
const keys = Object.keys(data);
|
|
174
|
+
const sorted = [...keys].toSorted();
|
|
175
|
+
if (keys.join(",") !== sorted.join(",")) {
|
|
176
|
+
issues.push({
|
|
177
|
+
severity: "warning",
|
|
178
|
+
model: model.id,
|
|
179
|
+
locale,
|
|
180
|
+
message: "Content file keys not in canonical order"
|
|
181
|
+
});
|
|
182
|
+
if (fix) {
|
|
183
|
+
const resorted = {};
|
|
184
|
+
for (const key of sorted) resorted[key] = data[key];
|
|
185
|
+
await writeJson(filePath, resorted);
|
|
186
|
+
fixed++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const [entryId, fields] of Object.entries(data)) {
|
|
190
|
+
entriesChecked++;
|
|
191
|
+
for (const [fieldName, value] of Object.entries(fields)) if (checkSecret(value)) issues.push({
|
|
192
|
+
severity: "error",
|
|
193
|
+
model: model.id,
|
|
194
|
+
locale,
|
|
195
|
+
entry: entryId,
|
|
196
|
+
field: fieldName,
|
|
197
|
+
message: `Potential secret detected in field "${fieldName}"`
|
|
198
|
+
});
|
|
199
|
+
if (!model.fields) continue;
|
|
200
|
+
for (const [fieldName, fieldDef] of Object.entries(model.fields)) {
|
|
201
|
+
const value = fields[fieldName];
|
|
202
|
+
if (fieldDef.required && (value === void 0 || value === null || value === "")) issues.push({
|
|
203
|
+
severity: "error",
|
|
204
|
+
model: model.id,
|
|
205
|
+
locale,
|
|
206
|
+
entry: entryId,
|
|
207
|
+
field: fieldName,
|
|
208
|
+
message: `Required field "${fieldName}" is missing`
|
|
209
|
+
});
|
|
210
|
+
if (value !== void 0 && value !== null && !fieldTypeMatches(value, fieldDef)) issues.push({
|
|
211
|
+
severity: "error",
|
|
212
|
+
model: model.id,
|
|
213
|
+
locale,
|
|
214
|
+
entry: entryId,
|
|
215
|
+
field: fieldName,
|
|
216
|
+
message: `Type mismatch: expected ${fieldDef.type}, got ${typeof value}`
|
|
217
|
+
});
|
|
218
|
+
if (value !== void 0 && value !== null) {
|
|
219
|
+
const minMaxErr = checkMinMax(value, fieldDef);
|
|
220
|
+
if (minMaxErr) issues.push({
|
|
221
|
+
severity: "error",
|
|
222
|
+
model: model.id,
|
|
223
|
+
locale,
|
|
224
|
+
entry: entryId,
|
|
225
|
+
field: fieldName,
|
|
226
|
+
message: minMaxErr
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (fieldDef.pattern && typeof value === "string") try {
|
|
230
|
+
if (!new RegExp(fieldDef.pattern).test(value)) issues.push({
|
|
231
|
+
severity: "error",
|
|
232
|
+
model: model.id,
|
|
233
|
+
locale,
|
|
234
|
+
entry: entryId,
|
|
235
|
+
field: fieldName,
|
|
236
|
+
message: `Value "${value}" does not match pattern /${fieldDef.pattern}/`
|
|
237
|
+
});
|
|
238
|
+
} catch {
|
|
239
|
+
issues.push({
|
|
240
|
+
severity: "warning",
|
|
241
|
+
model: model.id,
|
|
242
|
+
locale,
|
|
243
|
+
entry: entryId,
|
|
244
|
+
field: fieldName,
|
|
245
|
+
message: `Invalid pattern regex: ${fieldDef.pattern}`
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (fieldDef.unique && value !== void 0 && value !== null) {
|
|
249
|
+
const key = `${fieldName}:${locale}`;
|
|
250
|
+
if (!uniqueFieldValues[key]) uniqueFieldValues[key] = /* @__PURE__ */ new Map();
|
|
251
|
+
const existing = uniqueFieldValues[key].get(String(value));
|
|
252
|
+
if (existing) issues.push({
|
|
253
|
+
severity: "error",
|
|
254
|
+
model: model.id,
|
|
255
|
+
locale,
|
|
256
|
+
entry: entryId,
|
|
257
|
+
field: fieldName,
|
|
258
|
+
message: `Duplicate value "${value}" violates unique constraint (also in entry "${existing}")`
|
|
259
|
+
});
|
|
260
|
+
else uniqueFieldValues[key].set(String(value), entryId);
|
|
261
|
+
}
|
|
262
|
+
await checkRelation(projectRoot, fieldName, fieldDef, value, locale, {
|
|
263
|
+
model: model.id,
|
|
264
|
+
locale,
|
|
265
|
+
entry: entryId
|
|
266
|
+
}, issues);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (model.i18n && Object.keys(localeEntryIds).length > 1) {
|
|
271
|
+
const localeKeys = Object.keys(localeEntryIds);
|
|
272
|
+
for (let i = 1; i < localeKeys.length; i++) {
|
|
273
|
+
const locA = localeKeys[0];
|
|
274
|
+
const locB = localeKeys[i];
|
|
275
|
+
const idsA = localeEntryIds[locA];
|
|
276
|
+
const idsB = localeEntryIds[locB];
|
|
277
|
+
for (const id of idsA) if (!idsB.has(id)) issues.push({
|
|
278
|
+
severity: "error",
|
|
279
|
+
model: model.id,
|
|
280
|
+
locale: locB,
|
|
281
|
+
entry: id,
|
|
282
|
+
message: `Entry parity: entry "${id}" exists in ${locA} but missing in ${locB}`
|
|
283
|
+
});
|
|
284
|
+
for (const id of idsB) if (!idsA.has(id)) issues.push({
|
|
285
|
+
severity: "error",
|
|
286
|
+
model: model.id,
|
|
287
|
+
locale: locA,
|
|
288
|
+
entry: id,
|
|
289
|
+
message: `Entry parity: entry "${id}" exists in ${locB} but missing in ${locA}`
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const locale of locales) {
|
|
294
|
+
const metaPath = join(metaDir, `${locale}.json`);
|
|
295
|
+
const metaData = await readJson(metaPath);
|
|
296
|
+
const contentData = await readJson(resolveJsonFilePath(cDir, model, locale)) ?? {};
|
|
297
|
+
if (metaData) for (const metaEntryId of Object.keys(metaData)) {
|
|
298
|
+
validateScheduleFields(metaData[metaEntryId], {
|
|
299
|
+
model: model.id,
|
|
300
|
+
locale,
|
|
301
|
+
entry: metaEntryId
|
|
302
|
+
}, issues);
|
|
303
|
+
if (!(metaEntryId in contentData)) {
|
|
304
|
+
issues.push({
|
|
305
|
+
severity: "warning",
|
|
306
|
+
model: model.id,
|
|
307
|
+
locale,
|
|
308
|
+
entry: metaEntryId,
|
|
309
|
+
message: `Orphan meta: meta entry "${metaEntryId}" exists but content entry missing`
|
|
310
|
+
});
|
|
311
|
+
if (fix) {
|
|
312
|
+
delete metaData[metaEntryId];
|
|
313
|
+
if (Object.keys(metaData).length > 0) await writeJson(metaPath, metaData);
|
|
314
|
+
else await rm(metaPath, { force: true });
|
|
315
|
+
fixed++;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
for (const entryId of Object.keys(contentData)) if (!metaData || !(entryId in metaData)) {
|
|
320
|
+
issues.push({
|
|
321
|
+
severity: "warning",
|
|
322
|
+
model: model.id,
|
|
323
|
+
locale,
|
|
324
|
+
entry: entryId,
|
|
325
|
+
message: `Orphan content: entry "${entryId}" has no metadata`
|
|
326
|
+
});
|
|
327
|
+
if (fix) {
|
|
328
|
+
await writeMeta(projectRoot, model, {
|
|
329
|
+
locale,
|
|
330
|
+
entryId
|
|
331
|
+
}, {
|
|
332
|
+
status: "draft",
|
|
333
|
+
source: "import",
|
|
334
|
+
updated_by: "contentrain-mcp"
|
|
335
|
+
});
|
|
336
|
+
fixed++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
entries: entriesChecked,
|
|
342
|
+
fixed
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
async function validateSingletonModel(projectRoot, model, config, issues, fix) {
|
|
346
|
+
let entriesChecked = 0;
|
|
347
|
+
let fixed = 0;
|
|
348
|
+
const cDir = resolveContentDir(projectRoot, model);
|
|
349
|
+
for (const locale of config.locales.supported) {
|
|
350
|
+
const filePath = resolveJsonFilePath(cDir, model, locale);
|
|
351
|
+
const data = await readJson(filePath);
|
|
352
|
+
if (!data) {
|
|
353
|
+
if (model.i18n) {
|
|
354
|
+
issues.push({
|
|
355
|
+
severity: "error",
|
|
356
|
+
model: model.id,
|
|
357
|
+
locale,
|
|
358
|
+
message: `Locale file missing: ${locale}.json`
|
|
359
|
+
});
|
|
360
|
+
if (fix) {
|
|
361
|
+
await writeJson(filePath, {});
|
|
362
|
+
fixed++;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
entriesChecked++;
|
|
368
|
+
for (const [fieldName, value] of Object.entries(data)) if (checkSecret(value)) issues.push({
|
|
369
|
+
severity: "error",
|
|
370
|
+
model: model.id,
|
|
371
|
+
locale,
|
|
372
|
+
field: fieldName,
|
|
373
|
+
message: `Potential secret detected in field "${fieldName}"`
|
|
374
|
+
});
|
|
375
|
+
if (model.fields) for (const [fieldName, fieldDef] of Object.entries(model.fields)) {
|
|
376
|
+
const value = data[fieldName];
|
|
377
|
+
if (fieldDef.required && (value === void 0 || value === null || value === "")) issues.push({
|
|
378
|
+
severity: "error",
|
|
379
|
+
model: model.id,
|
|
380
|
+
locale,
|
|
381
|
+
field: fieldName,
|
|
382
|
+
message: `Required field "${fieldName}" is missing`
|
|
383
|
+
});
|
|
384
|
+
if (value !== void 0 && value !== null && !fieldTypeMatches(value, fieldDef)) issues.push({
|
|
385
|
+
severity: "error",
|
|
386
|
+
model: model.id,
|
|
387
|
+
locale,
|
|
388
|
+
field: fieldName,
|
|
389
|
+
message: `Type mismatch: expected ${fieldDef.type}, got ${typeof value}`
|
|
390
|
+
});
|
|
391
|
+
if (value !== void 0 && value !== null) {
|
|
392
|
+
const minMaxErr = checkMinMax(value, fieldDef);
|
|
393
|
+
if (minMaxErr) issues.push({
|
|
394
|
+
severity: "error",
|
|
395
|
+
model: model.id,
|
|
396
|
+
locale,
|
|
397
|
+
field: fieldName,
|
|
398
|
+
message: minMaxErr
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (fieldDef.pattern && typeof value === "string") try {
|
|
402
|
+
if (!new RegExp(fieldDef.pattern).test(value)) issues.push({
|
|
403
|
+
severity: "error",
|
|
404
|
+
model: model.id,
|
|
405
|
+
locale,
|
|
406
|
+
field: fieldName,
|
|
407
|
+
message: `Value "${value}" does not match pattern /${fieldDef.pattern}/`
|
|
408
|
+
});
|
|
409
|
+
} catch {
|
|
410
|
+
issues.push({
|
|
411
|
+
severity: "warning",
|
|
412
|
+
model: model.id,
|
|
413
|
+
locale,
|
|
414
|
+
field: fieldName,
|
|
415
|
+
message: `Invalid pattern regex: ${fieldDef.pattern}`
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
await checkRelation(projectRoot, fieldName, fieldDef, value, locale, {
|
|
419
|
+
model: model.id,
|
|
420
|
+
locale
|
|
421
|
+
}, issues);
|
|
422
|
+
}
|
|
423
|
+
const keys = Object.keys(data);
|
|
424
|
+
const sorted = [...keys].toSorted();
|
|
425
|
+
if (keys.join(",") !== sorted.join(",")) {
|
|
426
|
+
issues.push({
|
|
427
|
+
severity: "warning",
|
|
428
|
+
model: model.id,
|
|
429
|
+
locale,
|
|
430
|
+
message: "Content file keys not in canonical order"
|
|
431
|
+
});
|
|
432
|
+
if (fix) {
|
|
433
|
+
const resorted = {};
|
|
434
|
+
for (const key of sorted) resorted[key] = data[key];
|
|
435
|
+
await writeJson(filePath, resorted);
|
|
436
|
+
fixed++;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const singletonMetaData = await readJson(join(contentrainDir(projectRoot), "meta", model.id, `${locale}.json`));
|
|
440
|
+
if (singletonMetaData) validateScheduleFields(singletonMetaData, {
|
|
441
|
+
model: model.id,
|
|
442
|
+
locale
|
|
443
|
+
}, issues);
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
entries: entriesChecked,
|
|
447
|
+
fixed
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
async function validateDictionaryModel(projectRoot, model, config, issues, fix) {
|
|
451
|
+
let entriesChecked = 0;
|
|
452
|
+
let fixed = 0;
|
|
453
|
+
const cDir = resolveContentDir(projectRoot, model);
|
|
454
|
+
const localeKeys = {};
|
|
455
|
+
for (const locale of config.locales.supported) {
|
|
456
|
+
const filePath = resolveJsonFilePath(cDir, model, locale);
|
|
457
|
+
const data = await readJson(filePath);
|
|
458
|
+
if (!data) {
|
|
459
|
+
if (model.i18n) {
|
|
460
|
+
issues.push({
|
|
461
|
+
severity: "error",
|
|
462
|
+
model: model.id,
|
|
463
|
+
locale,
|
|
464
|
+
message: `Locale file missing: ${locale}.json`
|
|
465
|
+
});
|
|
466
|
+
if (fix) {
|
|
467
|
+
await writeJson(filePath, {});
|
|
468
|
+
fixed++;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
entriesChecked++;
|
|
474
|
+
localeKeys[locale] = new Set(Object.keys(data));
|
|
475
|
+
for (const [key, value] of Object.entries(data)) if (checkSecret(value)) issues.push({
|
|
476
|
+
severity: "error",
|
|
477
|
+
model: model.id,
|
|
478
|
+
locale,
|
|
479
|
+
field: key,
|
|
480
|
+
message: `Potential secret detected in key "${key}"`
|
|
481
|
+
});
|
|
482
|
+
const keys = Object.keys(data);
|
|
483
|
+
const sorted = [...keys].toSorted();
|
|
484
|
+
if (keys.join(",") !== sorted.join(",")) {
|
|
485
|
+
issues.push({
|
|
486
|
+
severity: "warning",
|
|
487
|
+
model: model.id,
|
|
488
|
+
locale,
|
|
489
|
+
message: "Content file keys not in canonical order"
|
|
490
|
+
});
|
|
491
|
+
if (fix) {
|
|
492
|
+
const resorted = {};
|
|
493
|
+
for (const key of sorted) resorted[key] = data[key];
|
|
494
|
+
await writeJson(filePath, resorted);
|
|
495
|
+
fixed++;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (model.i18n && Object.keys(localeKeys).length > 1) {
|
|
500
|
+
const localeNames = Object.keys(localeKeys);
|
|
501
|
+
for (let i = 1; i < localeNames.length; i++) {
|
|
502
|
+
const locA = localeNames[0];
|
|
503
|
+
const locB = localeNames[i];
|
|
504
|
+
const keysA = localeKeys[locA];
|
|
505
|
+
const keysB = localeKeys[locB];
|
|
506
|
+
for (const k of keysA) if (!keysB.has(k)) issues.push({
|
|
507
|
+
severity: "warning",
|
|
508
|
+
model: model.id,
|
|
509
|
+
locale: locB,
|
|
510
|
+
field: k,
|
|
511
|
+
message: `Key parity: key "${k}" exists in ${locA} but missing in ${locB}`
|
|
512
|
+
});
|
|
513
|
+
for (const k of keysB) if (!keysA.has(k)) issues.push({
|
|
514
|
+
severity: "warning",
|
|
515
|
+
model: model.id,
|
|
516
|
+
locale: locA,
|
|
517
|
+
field: k,
|
|
518
|
+
message: `Key parity: key "${k}" exists in ${locB} but missing in ${locA}`
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
entries: entriesChecked,
|
|
524
|
+
fixed
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
async function discoverDocumentSlugs(cDir, model) {
|
|
528
|
+
const strategy = resolveLocaleStrategy(model);
|
|
529
|
+
const entries = await readDir(cDir);
|
|
530
|
+
if (!model.i18n) return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
531
|
+
if (strategy === "file") return entries.filter((e) => !e.startsWith("."));
|
|
532
|
+
if (strategy === "suffix") {
|
|
533
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
534
|
+
for (const f of entries) {
|
|
535
|
+
if (!f.endsWith(".md")) continue;
|
|
536
|
+
const parts = f.replace(/\.md$/, "").split(".");
|
|
537
|
+
if (parts.length >= 2) {
|
|
538
|
+
parts.pop();
|
|
539
|
+
slugs.add(parts.join("."));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return [...slugs];
|
|
543
|
+
}
|
|
544
|
+
if (strategy === "directory") {
|
|
545
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
546
|
+
for (const localeDir of entries) {
|
|
547
|
+
if (localeDir.startsWith(".")) continue;
|
|
548
|
+
const files = await readDir(join(cDir, localeDir));
|
|
549
|
+
for (const f of files) if (f.endsWith(".md")) slugs.add(f.replace(/\.md$/, ""));
|
|
550
|
+
}
|
|
551
|
+
return [...slugs];
|
|
552
|
+
}
|
|
553
|
+
return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
554
|
+
}
|
|
555
|
+
async function validateDocumentModel(projectRoot, model, config, issues, fix) {
|
|
556
|
+
let entriesChecked = 0;
|
|
557
|
+
let fixed = 0;
|
|
558
|
+
const cDir = resolveContentDir(projectRoot, model);
|
|
559
|
+
if (!await pathExists(cDir)) return {
|
|
560
|
+
entries: 0,
|
|
561
|
+
fixed: 0
|
|
562
|
+
};
|
|
563
|
+
const slugs = await discoverDocumentSlugs(cDir, model);
|
|
564
|
+
const locales = config.locales.supported;
|
|
565
|
+
for (const slug of slugs) {
|
|
566
|
+
if (slug.startsWith(".")) continue;
|
|
567
|
+
for (const locale of locales) {
|
|
568
|
+
const filePath = resolveMdFilePath(cDir, model, locale, slug);
|
|
569
|
+
const raw = await readText(filePath);
|
|
570
|
+
if (!raw) {
|
|
571
|
+
if (model.i18n) {
|
|
572
|
+
issues.push({
|
|
573
|
+
severity: "warning",
|
|
574
|
+
model: model.id,
|
|
575
|
+
locale,
|
|
576
|
+
slug,
|
|
577
|
+
message: `Missing translation: document "${slug}" missing ${locale} locale file`
|
|
578
|
+
});
|
|
579
|
+
if (fix) {
|
|
580
|
+
await writeText(filePath, `---\nslug: ${slug}\n---\n`);
|
|
581
|
+
fixed++;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
entriesChecked++;
|
|
587
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
588
|
+
for (const [fieldName, value] of Object.entries(frontmatter)) if (checkSecret(value)) issues.push({
|
|
589
|
+
severity: "error",
|
|
590
|
+
model: model.id,
|
|
591
|
+
locale,
|
|
592
|
+
slug,
|
|
593
|
+
field: fieldName,
|
|
594
|
+
message: `Potential secret detected in field "${fieldName}"`
|
|
595
|
+
});
|
|
596
|
+
if (checkSecret(body)) issues.push({
|
|
597
|
+
severity: "error",
|
|
598
|
+
model: model.id,
|
|
599
|
+
locale,
|
|
600
|
+
slug,
|
|
601
|
+
field: "body",
|
|
602
|
+
message: "Potential secret detected in document body"
|
|
603
|
+
});
|
|
604
|
+
if (model.fields) for (const [fieldName, fieldDef] of Object.entries(model.fields)) {
|
|
605
|
+
if (fieldName === "body") continue;
|
|
606
|
+
const value = frontmatter[fieldName];
|
|
607
|
+
if (fieldDef.required && (value === void 0 || value === null || value === "")) issues.push({
|
|
608
|
+
severity: "error",
|
|
609
|
+
model: model.id,
|
|
610
|
+
locale,
|
|
611
|
+
slug,
|
|
612
|
+
field: fieldName,
|
|
613
|
+
message: `Required field "${fieldName}" is missing`
|
|
614
|
+
});
|
|
615
|
+
if (value !== void 0 && value !== null && !fieldTypeMatches(value, fieldDef)) issues.push({
|
|
616
|
+
severity: "error",
|
|
617
|
+
model: model.id,
|
|
618
|
+
locale,
|
|
619
|
+
slug,
|
|
620
|
+
field: fieldName,
|
|
621
|
+
message: `Type mismatch: expected ${fieldDef.type}, got ${typeof value}`
|
|
622
|
+
});
|
|
623
|
+
if (value !== void 0 && value !== null) {
|
|
624
|
+
const minMaxErr = checkMinMax(value, fieldDef);
|
|
625
|
+
if (minMaxErr) issues.push({
|
|
626
|
+
severity: "error",
|
|
627
|
+
model: model.id,
|
|
628
|
+
locale,
|
|
629
|
+
slug,
|
|
630
|
+
field: fieldName,
|
|
631
|
+
message: minMaxErr
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
if (fieldDef.pattern && typeof value === "string") try {
|
|
635
|
+
if (!new RegExp(fieldDef.pattern).test(value)) issues.push({
|
|
636
|
+
severity: "error",
|
|
637
|
+
model: model.id,
|
|
638
|
+
locale,
|
|
639
|
+
slug,
|
|
640
|
+
field: fieldName,
|
|
641
|
+
message: `Value "${value}" does not match pattern /${fieldDef.pattern}/`
|
|
642
|
+
});
|
|
643
|
+
} catch {
|
|
644
|
+
issues.push({
|
|
645
|
+
severity: "warning",
|
|
646
|
+
model: model.id,
|
|
647
|
+
locale,
|
|
648
|
+
slug,
|
|
649
|
+
field: fieldName,
|
|
650
|
+
message: `Invalid pattern regex: ${fieldDef.pattern}`
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
await checkRelation(projectRoot, fieldName, fieldDef, value, locale, {
|
|
654
|
+
model: model.id,
|
|
655
|
+
locale,
|
|
656
|
+
slug
|
|
657
|
+
}, issues);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
entries: entriesChecked,
|
|
663
|
+
fixed
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
async function checkOrphanContent(projectRoot, validModelIds, issues, _fix) {
|
|
667
|
+
let fixed = 0;
|
|
668
|
+
const contentBase = join(contentrainDir(projectRoot), "content");
|
|
669
|
+
const domains = await readDir(contentBase);
|
|
670
|
+
for (const domain of domains) {
|
|
671
|
+
if (domain.startsWith(".")) continue;
|
|
672
|
+
const modelDirs = await readDir(join(contentBase, domain));
|
|
673
|
+
for (const modelDir of modelDirs) {
|
|
674
|
+
if (modelDir.startsWith(".")) continue;
|
|
675
|
+
if (!validModelIds.has(modelDir)) issues.push({
|
|
676
|
+
severity: "warning",
|
|
677
|
+
model: modelDir,
|
|
678
|
+
message: `Orphan content: content directory exists for deleted model "${modelDir}"`
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return fixed;
|
|
683
|
+
}
|
|
684
|
+
async function validateProject(projectRoot, options) {
|
|
685
|
+
const issues = [];
|
|
686
|
+
const fix = options?.fix ?? false;
|
|
687
|
+
let totalEntries = 0;
|
|
688
|
+
let totalFixed = 0;
|
|
689
|
+
let modelsChecked = 0;
|
|
690
|
+
const config = await readConfig(projectRoot);
|
|
691
|
+
if (!config) return {
|
|
692
|
+
valid: false,
|
|
693
|
+
summary: {
|
|
694
|
+
errors: 1,
|
|
695
|
+
warnings: 0,
|
|
696
|
+
notices: 0,
|
|
697
|
+
models_checked: 0,
|
|
698
|
+
entries_checked: 0
|
|
699
|
+
},
|
|
700
|
+
issues: [{
|
|
701
|
+
severity: "error",
|
|
702
|
+
message: "Project not initialized: config.json missing"
|
|
703
|
+
}],
|
|
704
|
+
fixed: 0
|
|
705
|
+
};
|
|
706
|
+
const modelSummaries = await listModels(projectRoot);
|
|
707
|
+
const validModelIds = new Set(modelSummaries.map((m) => m.id));
|
|
708
|
+
const modelsToCheck = options?.model ? modelSummaries.filter((m) => m.id === options.model) : modelSummaries;
|
|
709
|
+
for (const summary of modelsToCheck) {
|
|
710
|
+
const model = await readModel(projectRoot, summary.id);
|
|
711
|
+
if (!model) continue;
|
|
712
|
+
modelsChecked++;
|
|
713
|
+
let result;
|
|
714
|
+
switch (model.kind) {
|
|
715
|
+
case "collection":
|
|
716
|
+
result = await validateCollectionModel(projectRoot, model, config, issues, fix);
|
|
717
|
+
break;
|
|
718
|
+
case "singleton":
|
|
719
|
+
result = await validateSingletonModel(projectRoot, model, config, issues, fix);
|
|
720
|
+
break;
|
|
721
|
+
case "dictionary":
|
|
722
|
+
result = await validateDictionaryModel(projectRoot, model, config, issues, fix);
|
|
723
|
+
break;
|
|
724
|
+
case "document":
|
|
725
|
+
result = await validateDocumentModel(projectRoot, model, config, issues, fix);
|
|
726
|
+
break;
|
|
727
|
+
default: result = {
|
|
728
|
+
entries: 0,
|
|
729
|
+
fixed: 0
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
totalEntries += result.entries;
|
|
733
|
+
totalFixed += result.fixed;
|
|
734
|
+
}
|
|
735
|
+
if (!options?.model) totalFixed += await checkOrphanContent(projectRoot, validModelIds, issues, fix);
|
|
736
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
737
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
738
|
+
const notices = issues.filter((i) => i.severity === "notice").length;
|
|
739
|
+
return {
|
|
740
|
+
valid: errors === 0,
|
|
741
|
+
summary: {
|
|
742
|
+
errors,
|
|
743
|
+
warnings,
|
|
744
|
+
notices,
|
|
745
|
+
models_checked: modelsChecked,
|
|
746
|
+
entries_checked: totalEntries
|
|
747
|
+
},
|
|
748
|
+
issues,
|
|
749
|
+
fixed: totalFixed
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
//#endregion
|
|
753
|
+
export { validateProject as t };
|
|
754
|
+
|
|
755
|
+
//# sourceMappingURL=validator-ku84AyFr.mjs.map
|