@contentrain/mcp 1.2.1 → 1.3.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/README.md +93 -25
- package/dist/annotations-Clhbw2lh.mjs +126 -0
- package/dist/annotations-Clhbw2lh.mjs.map +1 -0
- package/dist/{apply-manager-BkPMfpsN.mjs → apply-manager-D5ym5Jba.mjs} +7 -7
- package/dist/{apply-manager-BkPMfpsN.mjs.map → apply-manager-D5ym5Jba.mjs.map} +1 -1
- package/dist/{astro-parser-BJ-54JGe.mjs → astro-parser-B-nLl0zE.mjs} +3 -3
- package/dist/{astro-parser-BJ-54JGe.mjs.map → astro-parser-B-nLl0zE.mjs.map} +1 -1
- package/dist/{branch-lifecycle-wGJg25LT.mjs → branch-lifecycle-CFVpTpNT.mjs} +33 -3
- package/dist/{branch-lifecycle-wGJg25LT.mjs.map → branch-lifecycle-CFVpTpNT.mjs.map} +1 -1
- package/dist/{compiler-rIC4ETHv.mjs → compiler-k4yttlzM.mjs} +2 -2
- package/dist/{compiler-rIC4ETHv.mjs.map → compiler-k4yttlzM.mjs.map} +1 -1
- package/dist/{compiler-sfc.cjs-BbD04rn_.mjs → compiler-sfc.cjs-D0d_OicF.mjs} +2 -2
- package/dist/{compiler-sfc.cjs-BbD04rn_.mjs.map → compiler-sfc.cjs-D0d_OicF.mjs.map} +1 -1
- package/dist/config-BIl5Ybj0.mjs +40 -0
- package/dist/config-BIl5Ybj0.mjs.map +1 -0
- package/dist/content-manager-DqDLSNQ-.d.mts +63 -0
- package/dist/content-manager-DqDLSNQ-.d.mts.map +1 -0
- package/dist/context-J0OU9Nfp.mjs +102 -0
- package/dist/context-J0OU9Nfp.mjs.map +1 -0
- package/dist/contracts-DfL0BfrD.mjs +2 -0
- package/dist/core/apply-manager.mjs +10 -10
- package/dist/core/config.d.mts +13 -0
- package/dist/core/config.d.mts.map +1 -1
- package/dist/core/config.mjs +2 -3
- package/dist/core/content-manager.d.mts +2 -45
- package/dist/core/content-manager.mjs +3 -4
- package/dist/core/context.d.mts +32 -2
- package/dist/core/context.d.mts.map +1 -1
- package/dist/core/context.mjs +7 -7
- package/dist/core/contracts/index.d.mts +2 -0
- package/dist/core/contracts/index.mjs +2 -0
- package/dist/core/doctor.d.mts +76 -0
- package/dist/core/doctor.d.mts.map +1 -0
- package/dist/core/doctor.mjs +8 -0
- package/dist/core/graph-builder.mjs +3 -4
- package/dist/core/meta-manager.mjs +2 -3
- package/dist/core/model-manager.d.mts +34 -1
- package/dist/core/model-manager.d.mts.map +1 -1
- package/dist/core/model-manager.mjs +3 -4
- package/dist/core/ops/index.d.mts +137 -0
- package/dist/core/ops/index.d.mts.map +1 -0
- package/dist/core/ops/index.mjs +4 -0
- package/dist/core/overlay-reader.d.mts +42 -0
- package/dist/core/overlay-reader.d.mts.map +1 -0
- package/dist/core/overlay-reader.mjs +2 -0
- package/dist/core/scan-config.mjs +2 -3
- package/dist/core/scanner.mjs +4 -5
- package/dist/core/validator/index.d.mts +129 -0
- package/dist/core/validator/index.d.mts.map +1 -0
- package/dist/core/validator/index.mjs +6 -0
- package/dist/{detect-DinOQAmK.mjs → detect-DL9mFmaS.mjs} +2 -2
- package/dist/{detect-DinOQAmK.mjs.map → detect-DL9mFmaS.mjs.map} +1 -1
- package/dist/doctor-CgBoT6h1.mjs +338 -0
- package/dist/doctor-CgBoT6h1.mjs.map +1 -0
- package/dist/{fs-BnIQ4dKj.mjs → fs-DLbVB-Ek.mjs} +2 -2
- package/dist/fs-DLbVB-Ek.mjs.map +1 -0
- package/dist/git/branch-lifecycle.d.mts +30 -1
- package/dist/git/branch-lifecycle.d.mts.map +1 -1
- package/dist/git/branch-lifecycle.mjs +4 -5
- package/dist/git/transaction.d.mts +2 -2
- package/dist/git/transaction.d.mts.map +1 -1
- package/dist/git/transaction.mjs +8 -8
- package/dist/{graph-builder-tM7zj-SX.mjs → graph-builder-DXv2JEp-.mjs} +3 -3
- package/dist/{graph-builder-tM7zj-SX.mjs.map → graph-builder-DXv2JEp-.mjs.map} +1 -1
- package/dist/{id-DV_T9Ic8.mjs → id-CIO9s0x_.mjs} +1 -1
- package/dist/{id-DV_T9Ic8.mjs.map → id-CIO9s0x_.mjs.map} +1 -1
- package/dist/index-D0wQJSgs.d.mts +2 -0
- package/dist/index.mjs +22 -17
- package/dist/index.mjs.map +1 -1
- package/dist/local-DvP4Y4T-.mjs +182 -0
- package/dist/local-DvP4Y4T-.mjs.map +1 -0
- package/dist/{meta-manager--0IwAPCl.mjs → meta-manager-C900rEyG.mjs} +2 -2
- package/dist/{meta-manager--0IwAPCl.mjs.map → meta-manager-C900rEyG.mjs.map} +1 -1
- package/dist/{model-manager-DW_fOGRM.mjs → model-manager-BhLsUgaB.mjs} +241 -49
- package/dist/model-manager-BhLsUgaB.mjs.map +1 -0
- package/dist/{node-Cbq5eR5i.mjs → node-DN9hZeil.mjs} +1 -1
- package/dist/{node-Cbq5eR5i.mjs.map → node-DN9hZeil.mjs.map} +1 -1
- package/dist/ops-DjEWi5Wa.mjs +486 -0
- package/dist/ops-DjEWi5Wa.mjs.map +1 -0
- package/dist/overlay-reader-DOdyDRIv.mjs +74 -0
- package/dist/overlay-reader-DOdyDRIv.mjs.map +1 -0
- package/dist/paths-C3xNyc7z.mjs +52 -0
- package/dist/paths-C3xNyc7z.mjs.map +1 -0
- package/dist/paths-CYl5vCBD.mjs +54 -0
- package/dist/paths-CYl5vCBD.mjs.map +1 -0
- package/dist/providers/github/index.d.mts +144 -0
- package/dist/providers/github/index.d.mts.map +1 -0
- package/dist/providers/github/index.mjs +414 -0
- package/dist/providers/github/index.mjs.map +1 -0
- package/dist/providers/gitlab/index.d.mts +158 -0
- package/dist/providers/gitlab/index.d.mts.map +1 -0
- package/dist/providers/gitlab/index.mjs +361 -0
- package/dist/providers/gitlab/index.mjs.map +1 -0
- package/dist/providers/local/index.d.mts +106 -0
- package/dist/providers/local/index.d.mts.map +1 -0
- package/dist/providers/local/index.mjs +12 -0
- package/dist/{scan-config-QgnJ1-52.mjs → scan-config-jC6XmiqF.mjs} +2 -2
- package/dist/{scan-config-QgnJ1-52.mjs.map → scan-config-jC6XmiqF.mjs.map} +1 -1
- package/dist/{scanner-DUoXnmtI.mjs → scanner-DKMwc03k.mjs} +7 -7
- package/dist/{scanner-DUoXnmtI.mjs.map → scanner-DKMwc03k.mjs.map} +1 -1
- package/dist/serialization-B1CEzR4H.mjs +2 -0
- package/dist/server/http/index.d.mts +65 -0
- package/dist/server/http/index.d.mts.map +1 -0
- package/dist/server/http/index.mjs +137 -0
- package/dist/server/http/index.mjs.map +1 -0
- package/dist/{server-Dtq8vqbK.mjs → server-B1OJrUQ9.mjs} +486 -355
- package/dist/server-B1OJrUQ9.mjs.map +1 -0
- package/dist/server-WqGx6ait.d.mts +50 -0
- package/dist/server-WqGx6ait.d.mts.map +1 -0
- package/dist/server.d.mts +2 -7
- package/dist/server.mjs +22 -17
- package/dist/{source-map-generator-DpO-ejrt.mjs → source-map-generator-BWDva49I.mjs} +1 -1
- package/dist/{source-map-generator-DpO-ejrt.mjs.map → source-map-generator-BWDva49I.mjs.map} +1 -1
- package/dist/{svelte-parser-1bBcMx3T.mjs → svelte-parser-Dr9mmZuk.mjs} +3 -3
- package/dist/{svelte-parser-1bBcMx3T.mjs.map → svelte-parser-Dr9mmZuk.mjs.map} +1 -1
- package/dist/templates/index.mjs +1 -1
- package/dist/{templates-bRPzYq4M.mjs → templates-VrLVqfe5.mjs} +1 -1
- package/dist/{templates-bRPzYq4M.mjs.map → templates-VrLVqfe5.mjs.map} +1 -1
- package/dist/tools/annotations.d.mts +24 -0
- package/dist/tools/annotations.d.mts.map +1 -0
- package/dist/tools/annotations.mjs +2 -0
- package/dist/{transaction-C_Pvux78.mjs → transaction-DgkNgWSI.mjs} +61 -32
- package/dist/transaction-DgkNgWSI.mjs.map +1 -0
- package/dist/tsx-parser-BOMZE_yQ.mjs +2 -0
- package/dist/{tsx-parser-BWiMGAIR.mjs → tsx-parser-CQc6nr-o.mjs} +1 -1
- package/dist/{tsx-parser-BWiMGAIR.mjs.map → tsx-parser-CQc6nr-o.mjs.map} +1 -1
- package/dist/util/detect.mjs +2 -3
- package/dist/util/fs.mjs +1 -2
- package/dist/util/id.mjs +1 -1
- package/dist/validator-B0nNRFeU.mjs +906 -0
- package/dist/validator-B0nNRFeU.mjs.map +1 -0
- package/dist/{vue-parser-DTPTN3Yb.mjs → vue-parser-COgjArqv.mjs} +3 -3
- package/dist/{vue-parser-DTPTN3Yb.mjs.map → vue-parser-COgjArqv.mjs.map} +1 -1
- package/package.json +75 -21
- package/dist/config-B5kc2oeV.mjs +0 -27
- package/dist/config-B5kc2oeV.mjs.map +0 -1
- package/dist/context-DIDt_TxX.mjs +0 -46
- package/dist/context-DIDt_TxX.mjs.map +0 -1
- package/dist/core/content-manager.d.mts.map +0 -1
- package/dist/core/validator.d.mts +0 -23
- package/dist/core/validator.d.mts.map +0 -1
- package/dist/core/validator.mjs +0 -7
- package/dist/fs-BnIQ4dKj.mjs.map +0 -1
- package/dist/model-manager-DW_fOGRM.mjs.map +0 -1
- package/dist/serializer-CnHTtmc1.mjs +0 -2
- package/dist/server-Dtq8vqbK.mjs.map +0 -1
- package/dist/server.d.mts.map +0 -1
- package/dist/transaction-C_Pvux78.mjs.map +0 -1
- package/dist/tsx-parser-BFLv292Y.mjs +0 -2
- package/dist/util/serializer.d.mts +0 -2
- package/dist/util/serializer.mjs +0 -2
- package/dist/validator-SQYUV2JP.mjs +0 -581
- package/dist/validator-SQYUV2JP.mjs.map +0 -1
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
import { i as metaFilePath, n as contentFilePath, r as documentFilePath, t as contentDirPath } from "./paths-CYl5vCBD.mjs";
|
|
2
|
+
import { c as writeText, s as writeJson } from "./fs-DLbVB-Ek.mjs";
|
|
3
|
+
import { t as readConfig } from "./config-BIl5Ybj0.mjs";
|
|
4
|
+
import { C as LocalReader, f as parseFrontmatter, g as resolveLocaleStrategy, o as listModels, s as readModel } from "./model-manager-BhLsUgaB.mjs";
|
|
5
|
+
import { r as writeMeta } from "./meta-manager-C900rEyG.mjs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { detectSecrets, validateFieldValue } from "@contentrain/types";
|
|
8
|
+
import { rm } from "node:fs/promises";
|
|
9
|
+
//#region src/core/validator/entry.ts
|
|
10
|
+
/**
|
|
11
|
+
* Validate a single content entry against its model's field schema.
|
|
12
|
+
*
|
|
13
|
+
* Merges the rule sets from MCP's legacy `validator.ts` (secret detection,
|
|
14
|
+
* schema validation, unique constraints) with Studio's `content-validation.ts`
|
|
15
|
+
* (email/url heuristics, polymorphic relation structure, nested object and
|
|
16
|
+
* array-of-object recursion). The union is the authoritative per-entry
|
|
17
|
+
* validator — both MCP's project validator and Studio's save path should
|
|
18
|
+
* converge on this function over time.
|
|
19
|
+
*
|
|
20
|
+
* Asynchronous relation-integrity checks (does the referenced entry exist?)
|
|
21
|
+
* live in `relation-integrity.ts` because they require I/O.
|
|
22
|
+
*/
|
|
23
|
+
function validateContent(data, fields, modelId, locale, entryId, ctx) {
|
|
24
|
+
const errors = [];
|
|
25
|
+
for (const [fieldId, def] of Object.entries(fields)) {
|
|
26
|
+
const value = data[fieldId];
|
|
27
|
+
errors.push(...validateField(value, def, modelId, locale, entryId, fieldId, ctx));
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
valid: errors.filter((e) => e.severity === "error").length === 0,
|
|
31
|
+
errors
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function validateField(value, def, modelId, locale, entryId, fieldId, ctx) {
|
|
35
|
+
const errors = [];
|
|
36
|
+
const errCtx = {
|
|
37
|
+
model: modelId,
|
|
38
|
+
locale,
|
|
39
|
+
entry: entryId,
|
|
40
|
+
field: fieldId
|
|
41
|
+
};
|
|
42
|
+
if (value !== null && value !== void 0 && value !== "") {
|
|
43
|
+
const secretErrors = detectSecrets(value);
|
|
44
|
+
for (const e of secretErrors) errors.push({
|
|
45
|
+
...e,
|
|
46
|
+
...errCtx
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (!(def.type === "relation" || def.type === "relations")) {
|
|
50
|
+
const fieldErrors = validateFieldValue(value, def);
|
|
51
|
+
if (fieldErrors.length > 0) {
|
|
52
|
+
for (const e of fieldErrors) errors.push({
|
|
53
|
+
...e,
|
|
54
|
+
...errCtx
|
|
55
|
+
});
|
|
56
|
+
if (fieldErrors.some((e) => e.severity === "error")) return errors;
|
|
57
|
+
}
|
|
58
|
+
} else if (def.required && (value === null || value === void 0 || value === "")) {
|
|
59
|
+
errors.push({
|
|
60
|
+
severity: "error",
|
|
61
|
+
...errCtx,
|
|
62
|
+
message: `${fieldId} is required`
|
|
63
|
+
});
|
|
64
|
+
return errors;
|
|
65
|
+
}
|
|
66
|
+
if (value === null || value === void 0) return errors;
|
|
67
|
+
if (def.unique && ctx?.allEntries) {
|
|
68
|
+
const valueKey = String(value);
|
|
69
|
+
for (const [otherId, otherEntry] of Object.entries(ctx.allEntries)) {
|
|
70
|
+
if (otherId === ctx.currentEntryId) continue;
|
|
71
|
+
const otherValue = otherEntry[fieldId];
|
|
72
|
+
if (otherValue !== null && otherValue !== void 0 && String(otherValue) === valueKey) {
|
|
73
|
+
errors.push({
|
|
74
|
+
severity: "error",
|
|
75
|
+
...errCtx,
|
|
76
|
+
message: `${fieldId} must be unique — "${String(value)}" already exists in entry ${otherId}`
|
|
77
|
+
});
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (def.type === "email" && typeof value === "string" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) errors.push({
|
|
83
|
+
severity: "warning",
|
|
84
|
+
...errCtx,
|
|
85
|
+
message: `${fieldId} may not be a valid email`
|
|
86
|
+
});
|
|
87
|
+
if (def.type === "url" && typeof value === "string" && !/^https?:\/\/.+/.test(value) && !value.startsWith("/")) errors.push({
|
|
88
|
+
severity: "warning",
|
|
89
|
+
...errCtx,
|
|
90
|
+
message: `${fieldId} may not be a valid URL`
|
|
91
|
+
});
|
|
92
|
+
if (def.type === "relation" && def.model) {
|
|
93
|
+
const targets = Array.isArray(def.model) ? def.model : [def.model];
|
|
94
|
+
if (targets.length > 1) if (typeof value !== "object" || value === null || !("model" in value) || !("ref" in value)) errors.push({
|
|
95
|
+
severity: "error",
|
|
96
|
+
...errCtx,
|
|
97
|
+
message: `${fieldId} must be { model, ref } for polymorphic relation`
|
|
98
|
+
});
|
|
99
|
+
else {
|
|
100
|
+
const polyVal = value;
|
|
101
|
+
if (!targets.includes(polyVal.model)) errors.push({
|
|
102
|
+
severity: "error",
|
|
103
|
+
...errCtx,
|
|
104
|
+
message: `${fieldId} target model "${polyVal.model}" must be one of: ${targets.join(", ")}`
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else if (typeof value !== "string") errors.push({
|
|
108
|
+
severity: "error",
|
|
109
|
+
...errCtx,
|
|
110
|
+
message: `${fieldId} must be a string (entry ID or slug)`
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (def.type === "relations") if (!Array.isArray(value)) errors.push({
|
|
114
|
+
severity: "error",
|
|
115
|
+
...errCtx,
|
|
116
|
+
message: `${fieldId} must be an array`
|
|
117
|
+
});
|
|
118
|
+
else {
|
|
119
|
+
if (def.min !== void 0 && value.length < def.min) errors.push({
|
|
120
|
+
severity: "error",
|
|
121
|
+
...errCtx,
|
|
122
|
+
message: `${fieldId} must have at least ${def.min} items`
|
|
123
|
+
});
|
|
124
|
+
if (def.max !== void 0 && value.length > def.max) errors.push({
|
|
125
|
+
severity: "error",
|
|
126
|
+
...errCtx,
|
|
127
|
+
message: `${fieldId} must have at most ${def.max} items`
|
|
128
|
+
});
|
|
129
|
+
for (let i = 0; i < value.length; i++) if (typeof value[i] !== "string") errors.push({
|
|
130
|
+
severity: "error",
|
|
131
|
+
...errCtx,
|
|
132
|
+
message: `${fieldId}[${i}] must be a string (entry ID or slug)`
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (def.type === "array" && Array.isArray(value) && def.items && typeof def.items === "string") for (let i = 0; i < value.length; i++) errors.push(...validateArrayItemType(value[i], def.items, errCtx, `${fieldId}[${i}]`));
|
|
136
|
+
if (def.type === "object" && def.fields && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
137
|
+
const nested = validateContent(value, def.fields, modelId, locale, entryId);
|
|
138
|
+
errors.push(...nested.errors);
|
|
139
|
+
}
|
|
140
|
+
if (def.type === "array" && Array.isArray(value) && def.items && typeof def.items === "object" && def.items.type === "object" && def.items.fields) {
|
|
141
|
+
for (let i = 0; i < value.length; i++) if (typeof value[i] === "object" && value[i] !== null) {
|
|
142
|
+
const nested = validateContent(value[i], def.items.fields, modelId, locale, entryId);
|
|
143
|
+
for (const e of nested.errors) errors.push({
|
|
144
|
+
...e,
|
|
145
|
+
field: `${fieldId}[${i}].${e.field}`
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return errors;
|
|
150
|
+
}
|
|
151
|
+
function validateArrayItemType(value, itemType, errCtx, fieldPath) {
|
|
152
|
+
const ctx = {
|
|
153
|
+
...errCtx,
|
|
154
|
+
field: fieldPath
|
|
155
|
+
};
|
|
156
|
+
switch (itemType) {
|
|
157
|
+
case "string":
|
|
158
|
+
case "email":
|
|
159
|
+
case "url":
|
|
160
|
+
case "slug":
|
|
161
|
+
case "image":
|
|
162
|
+
case "video":
|
|
163
|
+
case "file":
|
|
164
|
+
if (typeof value !== "string") return [{
|
|
165
|
+
severity: "error",
|
|
166
|
+
...ctx,
|
|
167
|
+
message: `${fieldPath} must be a string`
|
|
168
|
+
}];
|
|
169
|
+
break;
|
|
170
|
+
case "number":
|
|
171
|
+
case "integer":
|
|
172
|
+
case "decimal":
|
|
173
|
+
if (typeof value !== "number") return [{
|
|
174
|
+
severity: "error",
|
|
175
|
+
...ctx,
|
|
176
|
+
message: `${fieldPath} must be a number`
|
|
177
|
+
}];
|
|
178
|
+
if (itemType === "integer" && !Number.isInteger(value)) return [{
|
|
179
|
+
severity: "error",
|
|
180
|
+
...ctx,
|
|
181
|
+
message: `${fieldPath} must be an integer`
|
|
182
|
+
}];
|
|
183
|
+
break;
|
|
184
|
+
case "boolean":
|
|
185
|
+
if (typeof value !== "boolean") return [{
|
|
186
|
+
severity: "error",
|
|
187
|
+
...ctx,
|
|
188
|
+
message: `${fieldPath} must be a boolean`
|
|
189
|
+
}];
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/core/validator/relation-integrity.ts
|
|
196
|
+
/**
|
|
197
|
+
* Verify that relation and relations fields reference targets that actually
|
|
198
|
+
* exist. Two severities are supported — Studio's per-save flow emits
|
|
199
|
+
* `warning` (the referenced entry may still be drafted), while MCP's
|
|
200
|
+
* project-wide validator emits `error` and additionally flags missing
|
|
201
|
+
* target models via `resolveTarget`.
|
|
202
|
+
*
|
|
203
|
+
* The loader abstraction keeps this function I/O-agnostic: MCP wires it
|
|
204
|
+
* to filesystem reads through LocalReader, Studio wires it to a
|
|
205
|
+
* `GitProvider.readFile`-backed loader, mocks pass in-memory maps.
|
|
206
|
+
*/
|
|
207
|
+
async function checkRelationIntegrity(data, fields, modelId, locale, entryId, loadContent, opts = {}) {
|
|
208
|
+
const errors = [];
|
|
209
|
+
const severity = opts.severity ?? "warning";
|
|
210
|
+
const resolve = opts.resolveTarget ?? (async (id, loc) => {
|
|
211
|
+
return {
|
|
212
|
+
exists: true,
|
|
213
|
+
content: await loadContent(id, loc)
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
for (const [fieldId, def] of Object.entries(fields)) {
|
|
217
|
+
const value = data[fieldId];
|
|
218
|
+
if (value === null || value === void 0) continue;
|
|
219
|
+
if (def.type === "relation" && def.model) {
|
|
220
|
+
const targets = Array.isArray(def.model) ? def.model : [def.model];
|
|
221
|
+
if (targets.length > 1 && typeof value === "object" && value !== null) {
|
|
222
|
+
const polyVal = value;
|
|
223
|
+
if (polyVal.model && polyVal.ref) {
|
|
224
|
+
const resolved = await resolve(polyVal.model, locale);
|
|
225
|
+
if (!resolved.exists) errors.push({
|
|
226
|
+
severity,
|
|
227
|
+
model: modelId,
|
|
228
|
+
locale,
|
|
229
|
+
entry: entryId,
|
|
230
|
+
field: fieldId,
|
|
231
|
+
message: `Broken relation: target model "${polyVal.model}" not found`
|
|
232
|
+
});
|
|
233
|
+
else if (resolved.content && !(polyVal.ref in resolved.content)) errors.push({
|
|
234
|
+
severity,
|
|
235
|
+
model: modelId,
|
|
236
|
+
locale,
|
|
237
|
+
entry: entryId,
|
|
238
|
+
field: fieldId,
|
|
239
|
+
message: `Broken relation: "${polyVal.ref}" not found in ${polyVal.model}`
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
} else if (typeof value === "string" && targets[0]) {
|
|
243
|
+
const resolved = await resolve(targets[0], locale);
|
|
244
|
+
if (!resolved.exists) errors.push({
|
|
245
|
+
severity,
|
|
246
|
+
model: modelId,
|
|
247
|
+
locale,
|
|
248
|
+
entry: entryId,
|
|
249
|
+
field: fieldId,
|
|
250
|
+
message: `Broken relation: target model "${targets[0]}" not found`
|
|
251
|
+
});
|
|
252
|
+
else if (resolved.content && !(value in resolved.content)) errors.push({
|
|
253
|
+
severity,
|
|
254
|
+
model: modelId,
|
|
255
|
+
locale,
|
|
256
|
+
entry: entryId,
|
|
257
|
+
field: fieldId,
|
|
258
|
+
message: `Broken relation: "${value}" not found in ${targets[0]}`
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (def.type === "relations" && def.model && Array.isArray(value)) {
|
|
263
|
+
const target = Array.isArray(def.model) ? def.model[0] : def.model;
|
|
264
|
+
if (target) {
|
|
265
|
+
const resolved = await resolve(target, locale);
|
|
266
|
+
if (!resolved.exists) errors.push({
|
|
267
|
+
severity,
|
|
268
|
+
model: modelId,
|
|
269
|
+
locale,
|
|
270
|
+
entry: entryId,
|
|
271
|
+
field: fieldId,
|
|
272
|
+
message: `Broken relation: target model "${target}" not found`
|
|
273
|
+
});
|
|
274
|
+
else if (resolved.content) {
|
|
275
|
+
for (const ref of value) if (typeof ref === "string" && !(ref in resolved.content)) errors.push({
|
|
276
|
+
severity,
|
|
277
|
+
model: modelId,
|
|
278
|
+
locale,
|
|
279
|
+
entry: entryId,
|
|
280
|
+
field: fieldId,
|
|
281
|
+
message: `Broken relation: "${ref}" not found in ${target}`
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return errors;
|
|
288
|
+
}
|
|
289
|
+
//#endregion
|
|
290
|
+
//#region src/core/validator/schedule.ts
|
|
291
|
+
/**
|
|
292
|
+
* Validate `publish_at` and `expire_at` meta fields.
|
|
293
|
+
*
|
|
294
|
+
* Rules (matches legacy `validator.ts:validateScheduleFields`):
|
|
295
|
+
* - `publish_at` must parse as a valid Date
|
|
296
|
+
* - `expire_at` must parse as a valid Date
|
|
297
|
+
* - When both are present, `expire_at` must be strictly after `publish_at`
|
|
298
|
+
*/
|
|
299
|
+
function validateScheduleFields(meta, ctx, issues) {
|
|
300
|
+
if (meta.publish_at !== void 0) {
|
|
301
|
+
const d = new Date(meta.publish_at);
|
|
302
|
+
if (Number.isNaN(d.getTime())) issues.push({
|
|
303
|
+
severity: "error",
|
|
304
|
+
...ctx,
|
|
305
|
+
message: `Invalid publish_at date: "${meta.publish_at}". Must be a valid ISO 8601 date string.`
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (meta.expire_at !== void 0) {
|
|
309
|
+
const d = new Date(meta.expire_at);
|
|
310
|
+
if (Number.isNaN(d.getTime())) issues.push({
|
|
311
|
+
severity: "error",
|
|
312
|
+
...ctx,
|
|
313
|
+
message: `Invalid expire_at date: "${meta.expire_at}". Must be a valid ISO 8601 date string.`
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (meta.publish_at !== void 0 && meta.expire_at !== void 0) {
|
|
317
|
+
const pubDate = new Date(meta.publish_at);
|
|
318
|
+
const expDate = new Date(meta.expire_at);
|
|
319
|
+
if (!Number.isNaN(pubDate.getTime()) && !Number.isNaN(expDate.getTime()) && expDate <= pubDate) issues.push({
|
|
320
|
+
severity: "error",
|
|
321
|
+
...ctx,
|
|
322
|
+
message: `expire_at ("${meta.expire_at}") must be after publish_at ("${meta.publish_at}").`
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/core/validator/project.ts
|
|
328
|
+
async function readJsonViaReader(reader, path) {
|
|
329
|
+
try {
|
|
330
|
+
return JSON.parse(await reader.readFile(path));
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function readTextViaReader(reader, path) {
|
|
336
|
+
try {
|
|
337
|
+
return await reader.readFile(path);
|
|
338
|
+
} catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Build a `resolveTarget` adapter for `checkRelationIntegrity` that walks
|
|
344
|
+
* the project's content store via the shared {@link RepoReader}. Mirrors
|
|
345
|
+
* the target-resolution shape the legacy `checkRelation` used — collection
|
|
346
|
+
* targets return their entry object-map, documents return a "slug exists"
|
|
347
|
+
* marker map, singletons and dictionaries return null content so the
|
|
348
|
+
* checker skips key enforcement for them.
|
|
349
|
+
*/
|
|
350
|
+
function buildProjectTargetResolver(reader) {
|
|
351
|
+
return async (targetModelId, targetLocale) => {
|
|
352
|
+
const targetModel = await readModel(reader, targetModelId);
|
|
353
|
+
if (!targetModel) return { exists: false };
|
|
354
|
+
if (targetModel.kind === "document") {
|
|
355
|
+
const slugs = await discoverDocumentSlugs(reader, contentDirPath(targetModel), targetModel);
|
|
356
|
+
return {
|
|
357
|
+
exists: true,
|
|
358
|
+
content: Object.fromEntries(slugs.map((s) => [s, true]))
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
if (targetModel.kind === "singleton" || targetModel.kind === "dictionary") return {
|
|
362
|
+
exists: true,
|
|
363
|
+
content: null
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
exists: true,
|
|
367
|
+
content: await readJsonViaReader(reader, contentFilePath(targetModel, targetLocale)) ?? {}
|
|
368
|
+
};
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Scan an entry's data fields for detected secrets in UNDECLARED keys —
|
|
373
|
+
* the legacy validator also flagged stray/rogue fields that were not in
|
|
374
|
+
* `model.fields`. `validateContent` only knows about declared fields, so
|
|
375
|
+
* this complementary pass preserves that coverage.
|
|
376
|
+
*/
|
|
377
|
+
function scanUndeclaredFieldsForSecrets(data, declared, ctx, issues) {
|
|
378
|
+
for (const [fieldName, value] of Object.entries(data)) {
|
|
379
|
+
if (declared && fieldName in declared) continue;
|
|
380
|
+
if (detectSecrets(value).length > 0) issues.push({
|
|
381
|
+
severity: "error",
|
|
382
|
+
...ctx,
|
|
383
|
+
field: fieldName,
|
|
384
|
+
message: `Potential secret detected in field "${fieldName}"`
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async function validateCollectionModel(reader, projectRoot, model, config, issues, fix) {
|
|
389
|
+
let entriesChecked = 0;
|
|
390
|
+
let fixed = 0;
|
|
391
|
+
const locales = config.locales.supported;
|
|
392
|
+
const localeEntryIds = {};
|
|
393
|
+
const allEntryIds = /* @__PURE__ */ new Set();
|
|
394
|
+
const resolveTarget = buildProjectTargetResolver(reader);
|
|
395
|
+
for (const locale of locales) {
|
|
396
|
+
const filePath = contentFilePath(model, locale);
|
|
397
|
+
const data = await readJsonViaReader(reader, filePath);
|
|
398
|
+
if (!data) {
|
|
399
|
+
if (model.i18n) {
|
|
400
|
+
issues.push({
|
|
401
|
+
severity: "error",
|
|
402
|
+
model: model.id,
|
|
403
|
+
locale,
|
|
404
|
+
message: `Locale file missing: ${locale}.json`
|
|
405
|
+
});
|
|
406
|
+
if (fix && projectRoot) {
|
|
407
|
+
await writeJson(join(projectRoot, filePath), {});
|
|
408
|
+
fixed++;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const ids = new Set(Object.keys(data));
|
|
414
|
+
localeEntryIds[locale] = ids;
|
|
415
|
+
for (const id of ids) allEntryIds.add(id);
|
|
416
|
+
const keys = Object.keys(data);
|
|
417
|
+
const sorted = [...keys].toSorted();
|
|
418
|
+
if (keys.join(",") !== sorted.join(",")) {
|
|
419
|
+
issues.push({
|
|
420
|
+
severity: "warning",
|
|
421
|
+
model: model.id,
|
|
422
|
+
locale,
|
|
423
|
+
message: "Content file keys not in canonical order"
|
|
424
|
+
});
|
|
425
|
+
if (fix && projectRoot) {
|
|
426
|
+
const resorted = {};
|
|
427
|
+
for (const key of sorted) resorted[key] = data[key];
|
|
428
|
+
await writeJson(join(projectRoot, filePath), resorted);
|
|
429
|
+
fixed++;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
for (const [entryId, fields] of Object.entries(data)) {
|
|
433
|
+
entriesChecked++;
|
|
434
|
+
scanUndeclaredFieldsForSecrets(fields, model.fields, {
|
|
435
|
+
model: model.id,
|
|
436
|
+
locale,
|
|
437
|
+
entry: entryId
|
|
438
|
+
}, issues);
|
|
439
|
+
if (!model.fields) continue;
|
|
440
|
+
const entryResult = validateContent(fields, model.fields, model.id, locale, entryId, {
|
|
441
|
+
allEntries: data,
|
|
442
|
+
currentEntryId: entryId
|
|
443
|
+
});
|
|
444
|
+
issues.push(...entryResult.errors);
|
|
445
|
+
const relationErrors = await checkRelationIntegrity(fields, model.fields, model.id, locale, entryId, async () => null, {
|
|
446
|
+
severity: "error",
|
|
447
|
+
resolveTarget
|
|
448
|
+
});
|
|
449
|
+
issues.push(...relationErrors);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (model.i18n && Object.keys(localeEntryIds).length > 1) {
|
|
453
|
+
const localeKeys = Object.keys(localeEntryIds);
|
|
454
|
+
for (let i = 1; i < localeKeys.length; i++) {
|
|
455
|
+
const locA = localeKeys[0];
|
|
456
|
+
const locB = localeKeys[i];
|
|
457
|
+
const idsA = localeEntryIds[locA];
|
|
458
|
+
const idsB = localeEntryIds[locB];
|
|
459
|
+
for (const id of idsA) if (!idsB.has(id)) issues.push({
|
|
460
|
+
severity: "error",
|
|
461
|
+
model: model.id,
|
|
462
|
+
locale: locB,
|
|
463
|
+
entry: id,
|
|
464
|
+
message: `Entry parity: entry "${id}" exists in ${locA} but missing in ${locB}`
|
|
465
|
+
});
|
|
466
|
+
for (const id of idsB) if (!idsA.has(id)) issues.push({
|
|
467
|
+
severity: "error",
|
|
468
|
+
model: model.id,
|
|
469
|
+
locale: locA,
|
|
470
|
+
entry: id,
|
|
471
|
+
message: `Entry parity: entry "${id}" exists in ${locB} but missing in ${locA}`
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
for (const locale of locales) {
|
|
476
|
+
const metaRelPath = metaFilePath(model, locale);
|
|
477
|
+
const metaData = await readJsonViaReader(reader, metaRelPath);
|
|
478
|
+
const contentData = await readJsonViaReader(reader, contentFilePath(model, locale)) ?? {};
|
|
479
|
+
if (metaData) for (const metaEntryId of Object.keys(metaData)) {
|
|
480
|
+
validateScheduleFields(metaData[metaEntryId], {
|
|
481
|
+
model: model.id,
|
|
482
|
+
locale,
|
|
483
|
+
entry: metaEntryId
|
|
484
|
+
}, issues);
|
|
485
|
+
if (!(metaEntryId in contentData)) {
|
|
486
|
+
issues.push({
|
|
487
|
+
severity: "warning",
|
|
488
|
+
model: model.id,
|
|
489
|
+
locale,
|
|
490
|
+
entry: metaEntryId,
|
|
491
|
+
message: `Orphan meta: meta entry "${metaEntryId}" exists but content entry missing`
|
|
492
|
+
});
|
|
493
|
+
if (fix && projectRoot) {
|
|
494
|
+
delete metaData[metaEntryId];
|
|
495
|
+
const metaAbs = join(projectRoot, metaRelPath);
|
|
496
|
+
if (Object.keys(metaData).length > 0) await writeJson(metaAbs, metaData);
|
|
497
|
+
else await rm(metaAbs, { force: true });
|
|
498
|
+
fixed++;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
for (const entryId of Object.keys(contentData)) if (!metaData || !(entryId in metaData)) {
|
|
503
|
+
issues.push({
|
|
504
|
+
severity: "warning",
|
|
505
|
+
model: model.id,
|
|
506
|
+
locale,
|
|
507
|
+
entry: entryId,
|
|
508
|
+
message: `Orphan content: entry "${entryId}" has no metadata`
|
|
509
|
+
});
|
|
510
|
+
if (fix && projectRoot) {
|
|
511
|
+
await writeMeta(projectRoot, model, {
|
|
512
|
+
locale,
|
|
513
|
+
entryId
|
|
514
|
+
}, {
|
|
515
|
+
status: "draft",
|
|
516
|
+
source: "import",
|
|
517
|
+
updated_by: "contentrain-mcp"
|
|
518
|
+
});
|
|
519
|
+
fixed++;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
entries: entriesChecked,
|
|
525
|
+
fixed
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
async function validateSingletonModel(reader, projectRoot, model, config, issues, fix) {
|
|
529
|
+
let entriesChecked = 0;
|
|
530
|
+
let fixed = 0;
|
|
531
|
+
for (const locale of config.locales.supported) {
|
|
532
|
+
const filePath = contentFilePath(model, locale);
|
|
533
|
+
const data = await readJsonViaReader(reader, filePath);
|
|
534
|
+
if (!data) {
|
|
535
|
+
if (model.i18n) {
|
|
536
|
+
issues.push({
|
|
537
|
+
severity: "error",
|
|
538
|
+
model: model.id,
|
|
539
|
+
locale,
|
|
540
|
+
message: `Locale file missing: ${locale}.json`
|
|
541
|
+
});
|
|
542
|
+
if (fix && projectRoot) {
|
|
543
|
+
await writeJson(join(projectRoot, filePath), {});
|
|
544
|
+
fixed++;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
entriesChecked++;
|
|
550
|
+
scanUndeclaredFieldsForSecrets(data, model.fields, {
|
|
551
|
+
model: model.id,
|
|
552
|
+
locale
|
|
553
|
+
}, issues);
|
|
554
|
+
if (model.fields) {
|
|
555
|
+
const entryResult = validateContent(data, model.fields, model.id, locale);
|
|
556
|
+
issues.push(...entryResult.errors);
|
|
557
|
+
const resolveTarget = buildProjectTargetResolver(reader);
|
|
558
|
+
const relationErrors = await checkRelationIntegrity(data, model.fields, model.id, locale, void 0, async () => null, {
|
|
559
|
+
severity: "error",
|
|
560
|
+
resolveTarget
|
|
561
|
+
});
|
|
562
|
+
issues.push(...relationErrors);
|
|
563
|
+
}
|
|
564
|
+
const keys = Object.keys(data);
|
|
565
|
+
const sorted = [...keys].toSorted();
|
|
566
|
+
if (keys.join(",") !== sorted.join(",")) {
|
|
567
|
+
issues.push({
|
|
568
|
+
severity: "warning",
|
|
569
|
+
model: model.id,
|
|
570
|
+
locale,
|
|
571
|
+
message: "Content file keys not in canonical order"
|
|
572
|
+
});
|
|
573
|
+
if (fix && projectRoot) {
|
|
574
|
+
const resorted = {};
|
|
575
|
+
for (const key of sorted) resorted[key] = data[key];
|
|
576
|
+
await writeJson(join(projectRoot, filePath), resorted);
|
|
577
|
+
fixed++;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const singletonMetaData = await readJsonViaReader(reader, metaFilePath(model, locale));
|
|
581
|
+
if (singletonMetaData) validateScheduleFields(singletonMetaData, {
|
|
582
|
+
model: model.id,
|
|
583
|
+
locale
|
|
584
|
+
}, issues);
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
entries: entriesChecked,
|
|
588
|
+
fixed
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
async function validateDictionaryModel(reader, projectRoot, model, config, issues, fix) {
|
|
592
|
+
let entriesChecked = 0;
|
|
593
|
+
let fixed = 0;
|
|
594
|
+
const localeKeys = {};
|
|
595
|
+
for (const locale of config.locales.supported) {
|
|
596
|
+
const filePath = contentFilePath(model, locale);
|
|
597
|
+
const data = await readJsonViaReader(reader, filePath);
|
|
598
|
+
if (!data) {
|
|
599
|
+
if (model.i18n) {
|
|
600
|
+
issues.push({
|
|
601
|
+
severity: "error",
|
|
602
|
+
model: model.id,
|
|
603
|
+
locale,
|
|
604
|
+
message: `Locale file missing: ${locale}.json`
|
|
605
|
+
});
|
|
606
|
+
if (fix && projectRoot) {
|
|
607
|
+
await writeJson(join(projectRoot, filePath), {});
|
|
608
|
+
fixed++;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
entriesChecked++;
|
|
614
|
+
localeKeys[locale] = new Set(Object.keys(data));
|
|
615
|
+
for (const [key, value] of Object.entries(data)) if (detectSecrets(value).length > 0) issues.push({
|
|
616
|
+
severity: "error",
|
|
617
|
+
model: model.id,
|
|
618
|
+
locale,
|
|
619
|
+
field: key,
|
|
620
|
+
message: `Potential secret detected in key "${key}"`
|
|
621
|
+
});
|
|
622
|
+
const valueToKeys = /* @__PURE__ */ new Map();
|
|
623
|
+
for (const [key, value] of Object.entries(data)) {
|
|
624
|
+
const arr = valueToKeys.get(value);
|
|
625
|
+
if (arr) arr.push(key);
|
|
626
|
+
else valueToKeys.set(value, [key]);
|
|
627
|
+
}
|
|
628
|
+
for (const [value, dupeKeys] of valueToKeys) if (dupeKeys.length > 1) {
|
|
629
|
+
const truncated = value.length > 40 ? `${value.slice(0, 40)}...` : value;
|
|
630
|
+
issues.push({
|
|
631
|
+
severity: "warning",
|
|
632
|
+
model: model.id,
|
|
633
|
+
locale,
|
|
634
|
+
message: `Duplicate value "${truncated}" mapped to ${dupeKeys.length} keys: [${dupeKeys.join(", ")}]`
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
const keys = Object.keys(data);
|
|
638
|
+
const sorted = [...keys].toSorted();
|
|
639
|
+
if (keys.join(",") !== sorted.join(",")) {
|
|
640
|
+
issues.push({
|
|
641
|
+
severity: "warning",
|
|
642
|
+
model: model.id,
|
|
643
|
+
locale,
|
|
644
|
+
message: "Content file keys not in canonical order"
|
|
645
|
+
});
|
|
646
|
+
if (fix && projectRoot) {
|
|
647
|
+
const resorted = {};
|
|
648
|
+
for (const key of sorted) resorted[key] = data[key];
|
|
649
|
+
await writeJson(join(projectRoot, filePath), resorted);
|
|
650
|
+
fixed++;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (model.i18n && Object.keys(localeKeys).length > 1) {
|
|
655
|
+
const localeNames = Object.keys(localeKeys);
|
|
656
|
+
for (let i = 1; i < localeNames.length; i++) {
|
|
657
|
+
const locA = localeNames[0];
|
|
658
|
+
const locB = localeNames[i];
|
|
659
|
+
const keysA = localeKeys[locA];
|
|
660
|
+
const keysB = localeKeys[locB];
|
|
661
|
+
for (const k of keysA) if (!keysB.has(k)) issues.push({
|
|
662
|
+
severity: "warning",
|
|
663
|
+
model: model.id,
|
|
664
|
+
locale: locB,
|
|
665
|
+
field: k,
|
|
666
|
+
message: `Key parity: key "${k}" exists in ${locA} but missing in ${locB}`
|
|
667
|
+
});
|
|
668
|
+
for (const k of keysB) if (!keysA.has(k)) issues.push({
|
|
669
|
+
severity: "warning",
|
|
670
|
+
model: model.id,
|
|
671
|
+
locale: locA,
|
|
672
|
+
field: k,
|
|
673
|
+
message: `Key parity: key "${k}" exists in ${locB} but missing in ${locA}`
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
entries: entriesChecked,
|
|
679
|
+
fixed
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
async function discoverDocumentSlugs(reader, cDir, model) {
|
|
683
|
+
const strategy = resolveLocaleStrategy(model);
|
|
684
|
+
const entries = await reader.listDirectory(cDir);
|
|
685
|
+
if (!model.i18n) return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
686
|
+
if (strategy === "file") return entries.filter((e) => !e.startsWith("."));
|
|
687
|
+
if (strategy === "suffix") {
|
|
688
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
689
|
+
for (const f of entries) {
|
|
690
|
+
if (!f.endsWith(".md")) continue;
|
|
691
|
+
const parts = f.replace(/\.md$/, "").split(".");
|
|
692
|
+
if (parts.length >= 2) {
|
|
693
|
+
parts.pop();
|
|
694
|
+
slugs.add(parts.join("."));
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return [...slugs];
|
|
698
|
+
}
|
|
699
|
+
if (strategy === "directory") {
|
|
700
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
701
|
+
const localeLists = await Promise.all(entries.filter((localeDir) => !localeDir.startsWith(".")).map((localeDir) => reader.listDirectory(`${cDir}/${localeDir}`)));
|
|
702
|
+
for (const files of localeLists) for (const f of files) if (f.endsWith(".md")) slugs.add(f.replace(/\.md$/, ""));
|
|
703
|
+
return [...slugs];
|
|
704
|
+
}
|
|
705
|
+
return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
706
|
+
}
|
|
707
|
+
async function validateDocumentModel(reader, projectRoot, model, config, issues, fix) {
|
|
708
|
+
let entriesChecked = 0;
|
|
709
|
+
let fixed = 0;
|
|
710
|
+
const cDir = contentDirPath(model);
|
|
711
|
+
if (!await reader.fileExists(cDir)) return {
|
|
712
|
+
entries: 0,
|
|
713
|
+
fixed: 0
|
|
714
|
+
};
|
|
715
|
+
const slugs = await discoverDocumentSlugs(reader, cDir, model);
|
|
716
|
+
const locales = config.locales.supported;
|
|
717
|
+
for (const slug of slugs) {
|
|
718
|
+
if (slug.startsWith(".")) continue;
|
|
719
|
+
for (const locale of locales) {
|
|
720
|
+
const filePath = documentFilePath(model, locale, slug);
|
|
721
|
+
const raw = await readTextViaReader(reader, filePath);
|
|
722
|
+
if (!raw) {
|
|
723
|
+
if (model.i18n) {
|
|
724
|
+
issues.push({
|
|
725
|
+
severity: "warning",
|
|
726
|
+
model: model.id,
|
|
727
|
+
locale,
|
|
728
|
+
slug,
|
|
729
|
+
message: `Missing translation: document "${slug}" missing ${locale} locale file`
|
|
730
|
+
});
|
|
731
|
+
if (fix && projectRoot) {
|
|
732
|
+
const template = `---\nslug: ${slug}\n---\n`;
|
|
733
|
+
await writeText(join(projectRoot, filePath), template);
|
|
734
|
+
fixed++;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
entriesChecked++;
|
|
740
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
741
|
+
scanUndeclaredFieldsForSecrets(frontmatter, {
|
|
742
|
+
...model.fields,
|
|
743
|
+
body: true
|
|
744
|
+
}, {
|
|
745
|
+
model: model.id,
|
|
746
|
+
locale,
|
|
747
|
+
slug
|
|
748
|
+
}, issues);
|
|
749
|
+
if (detectSecrets(body).length > 0) issues.push({
|
|
750
|
+
severity: "error",
|
|
751
|
+
model: model.id,
|
|
752
|
+
locale,
|
|
753
|
+
slug,
|
|
754
|
+
field: "body",
|
|
755
|
+
message: "Potential secret detected in document body"
|
|
756
|
+
});
|
|
757
|
+
if (model.fields) {
|
|
758
|
+
const fieldsWithoutBody = Object.fromEntries(Object.entries(model.fields).filter(([name]) => name !== "body"));
|
|
759
|
+
const entryResult = validateContent(frontmatter, fieldsWithoutBody, model.id, locale);
|
|
760
|
+
for (const err of entryResult.errors) issues.push({
|
|
761
|
+
...err,
|
|
762
|
+
slug
|
|
763
|
+
});
|
|
764
|
+
const resolveTarget = buildProjectTargetResolver(reader);
|
|
765
|
+
const relationErrors = await checkRelationIntegrity(frontmatter, fieldsWithoutBody, model.id, locale, void 0, async () => null, {
|
|
766
|
+
severity: "error",
|
|
767
|
+
resolveTarget
|
|
768
|
+
});
|
|
769
|
+
for (const err of relationErrors) issues.push({
|
|
770
|
+
...err,
|
|
771
|
+
slug
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return {
|
|
777
|
+
entries: entriesChecked,
|
|
778
|
+
fixed
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
async function checkOrphanContent(reader, validModelIds, issues, _fix) {
|
|
782
|
+
const fixed = 0;
|
|
783
|
+
const contentBase = ".contentrain/content";
|
|
784
|
+
const domains = await reader.listDirectory(contentBase);
|
|
785
|
+
const modelLists = await Promise.all(domains.filter((d) => !d.startsWith(".")).map(async (d) => ({
|
|
786
|
+
domain: d,
|
|
787
|
+
dirs: await reader.listDirectory(`${contentBase}/${d}`)
|
|
788
|
+
})));
|
|
789
|
+
for (const { dirs } of modelLists) for (const modelDir of dirs) {
|
|
790
|
+
if (modelDir.startsWith(".")) continue;
|
|
791
|
+
if (!validModelIds.has(modelDir)) issues.push({
|
|
792
|
+
severity: "warning",
|
|
793
|
+
model: modelDir,
|
|
794
|
+
message: `Orphan content: content directory exists for deleted model "${modelDir}"`
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
return fixed;
|
|
798
|
+
}
|
|
799
|
+
async function validateProject(input, options) {
|
|
800
|
+
const reader = typeof input === "string" ? new LocalReader(input) : input;
|
|
801
|
+
const projectRoot = typeof input === "string" ? input : void 0;
|
|
802
|
+
const fix = Boolean(options?.fix) && projectRoot !== void 0;
|
|
803
|
+
const issues = [];
|
|
804
|
+
let totalEntries = 0;
|
|
805
|
+
let totalFixed = 0;
|
|
806
|
+
let modelsChecked = 0;
|
|
807
|
+
const config = await readConfig(reader);
|
|
808
|
+
if (!config) return {
|
|
809
|
+
valid: false,
|
|
810
|
+
summary: {
|
|
811
|
+
errors: 1,
|
|
812
|
+
warnings: 0,
|
|
813
|
+
notices: 0,
|
|
814
|
+
models_checked: 0,
|
|
815
|
+
entries_checked: 0
|
|
816
|
+
},
|
|
817
|
+
issues: [{
|
|
818
|
+
severity: "error",
|
|
819
|
+
message: "Project not initialized: config.json missing"
|
|
820
|
+
}],
|
|
821
|
+
fixed: 0
|
|
822
|
+
};
|
|
823
|
+
const modelSummaries = await listModels(reader);
|
|
824
|
+
const validModelIds = new Set(modelSummaries.map((m) => m.id));
|
|
825
|
+
const modelsToCheck = options?.model ? modelSummaries.filter((m) => m.id === options.model) : modelSummaries;
|
|
826
|
+
for (const summary of modelsToCheck) {
|
|
827
|
+
const model = await readModel(reader, summary.id);
|
|
828
|
+
if (!model) continue;
|
|
829
|
+
modelsChecked++;
|
|
830
|
+
let result;
|
|
831
|
+
switch (model.kind) {
|
|
832
|
+
case "collection":
|
|
833
|
+
result = await validateCollectionModel(reader, projectRoot, model, config, issues, fix);
|
|
834
|
+
break;
|
|
835
|
+
case "singleton":
|
|
836
|
+
result = await validateSingletonModel(reader, projectRoot, model, config, issues, fix);
|
|
837
|
+
break;
|
|
838
|
+
case "dictionary":
|
|
839
|
+
result = await validateDictionaryModel(reader, projectRoot, model, config, issues, fix);
|
|
840
|
+
break;
|
|
841
|
+
case "document":
|
|
842
|
+
result = await validateDocumentModel(reader, projectRoot, model, config, issues, fix);
|
|
843
|
+
break;
|
|
844
|
+
default: result = {
|
|
845
|
+
entries: 0,
|
|
846
|
+
fixed: 0
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
totalEntries += result.entries;
|
|
850
|
+
totalFixed += result.fixed;
|
|
851
|
+
}
|
|
852
|
+
if (!options?.model) totalFixed += await checkOrphanContent(reader, validModelIds, issues, fix);
|
|
853
|
+
if (!options?.model) {
|
|
854
|
+
const dictModels = modelsToCheck.filter((m) => m.kind === "dictionary");
|
|
855
|
+
if (dictModels.length > 1) {
|
|
856
|
+
const globalValueMap = {};
|
|
857
|
+
for (const summary of dictModels) {
|
|
858
|
+
const model = await readModel(reader, summary.id);
|
|
859
|
+
if (!model) continue;
|
|
860
|
+
for (const locale of config.locales.supported) {
|
|
861
|
+
if (!globalValueMap[locale]) globalValueMap[locale] = /* @__PURE__ */ new Map();
|
|
862
|
+
const data = await readJsonViaReader(reader, contentFilePath(model, locale));
|
|
863
|
+
if (!data) continue;
|
|
864
|
+
for (const [key, value] of Object.entries(data)) {
|
|
865
|
+
const refs = globalValueMap[locale].get(value);
|
|
866
|
+
if (refs) refs.push({
|
|
867
|
+
model: model.id,
|
|
868
|
+
key
|
|
869
|
+
});
|
|
870
|
+
else globalValueMap[locale].set(value, [{
|
|
871
|
+
model: model.id,
|
|
872
|
+
key
|
|
873
|
+
}]);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
for (const [locale, valueMap] of Object.entries(globalValueMap)) for (const [value, refs] of valueMap) if (new Set(refs.map((r) => r.model)).size > 1) {
|
|
878
|
+
const truncated = value.length > 40 ? `${value.slice(0, 40)}...` : value;
|
|
879
|
+
issues.push({
|
|
880
|
+
severity: "notice",
|
|
881
|
+
locale,
|
|
882
|
+
message: `Cross-model duplicate value "${truncated}" in ${refs.map((r) => `${r.model}/${r.key}`).join(", ")}`
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
888
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
889
|
+
const notices = issues.filter((i) => i.severity === "notice").length;
|
|
890
|
+
return {
|
|
891
|
+
valid: errors === 0,
|
|
892
|
+
summary: {
|
|
893
|
+
errors,
|
|
894
|
+
warnings,
|
|
895
|
+
notices,
|
|
896
|
+
models_checked: modelsChecked,
|
|
897
|
+
entries_checked: totalEntries
|
|
898
|
+
},
|
|
899
|
+
issues,
|
|
900
|
+
fixed: totalFixed
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
//#endregion
|
|
904
|
+
export { validateContent as i, validateScheduleFields as n, checkRelationIntegrity as r, validateProject as t };
|
|
905
|
+
|
|
906
|
+
//# sourceMappingURL=validator-B0nNRFeU.mjs.map
|