@contentrain/mcp 1.2.0 → 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-CQOxARUU.mjs → scanner-DKMwc03k.mjs} +26 -8
- package/dist/scanner-DKMwc03k.mjs.map +1 -0
- 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-DfWRpzdP.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/scanner-CQOxARUU.mjs.map +0 -1
- package/dist/serializer-CnHTtmc1.mjs +0 -2
- package/dist/server-DfWRpzdP.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,486 @@
|
|
|
1
|
+
import { n as canonicalStringify$1, t as serializeMarkdownFrontmatter$1 } from "./serialization-B1CEzR4H.mjs";
|
|
2
|
+
import { i as metaFilePath, n as contentFilePath, r as documentFilePath } from "./paths-CYl5vCBD.mjs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { generateEntryId, validateEntryId, validateLocale, validateSlug } from "@contentrain/types";
|
|
5
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
//#region src/core/ops/apply-to-worktree.ts
|
|
7
|
+
/**
|
|
8
|
+
* Phase 2 shim — applies a `FileChange[]` to a local worktree directory.
|
|
9
|
+
*
|
|
10
|
+
* Phase 3 replaces this with `LocalProvider.applyPlan` (same logic, owned by
|
|
11
|
+
* the provider) while a `GitHubProvider.applyPlan` produces the equivalent
|
|
12
|
+
* commit through the Git Data API. Until then, tool handlers call this shim
|
|
13
|
+
* inside the existing `createTransaction` flow so Phase 2 gets plan/apply
|
|
14
|
+
* semantics without rewriting the transaction machinery.
|
|
15
|
+
*
|
|
16
|
+
* - `content: string` → `writeFile` (creates parent dirs).
|
|
17
|
+
* - `content: null` → `rm`; missing files are tolerated.
|
|
18
|
+
* - Changes are applied in their given order; callers should sort for
|
|
19
|
+
* determinism.
|
|
20
|
+
*/
|
|
21
|
+
async function applyChangesToWorktree(worktree, changes) {
|
|
22
|
+
for (const change of changes) {
|
|
23
|
+
const absolute = join(worktree, change.path);
|
|
24
|
+
if (change.content === null) {
|
|
25
|
+
await rm(absolute, { force: true }).catch(() => {});
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
29
|
+
await writeFile(absolute, change.content, "utf-8");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/core/ops/content-delete.ts
|
|
34
|
+
function contentDir(model) {
|
|
35
|
+
return model.content_path ?? `.contentrain/content/${model.domain}/${model.id}`;
|
|
36
|
+
}
|
|
37
|
+
async function tryReadJson(reader, path) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(await reader.readFile(path));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function discoverCollectionLocales(reader, model) {
|
|
45
|
+
const dir = contentDir(model);
|
|
46
|
+
const files = await reader.listDirectory(dir);
|
|
47
|
+
if ((model.locale_strategy ?? "file") === "directory") return files;
|
|
48
|
+
return files.filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", "").replace(`${model.id}.`, ""));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the FileChange[] required to delete content. Pure — touches no disk.
|
|
52
|
+
*
|
|
53
|
+
* Semantics preserved from legacy `deleteContent`:
|
|
54
|
+
* - Collection: remove the entry ID from each locale's content file and its
|
|
55
|
+
* meta file. Meta file is deleted outright if the removal empties it.
|
|
56
|
+
* - Singleton: delete the locale's content + meta file (locale-scoped when
|
|
57
|
+
* i18n, non-i18n removes whatever meta lives under `.contentrain/meta/{id}/`).
|
|
58
|
+
* - Dictionary: two modes — key-scoped removal updates the file in place,
|
|
59
|
+
* or locale-scoped removes the content + meta pair.
|
|
60
|
+
* - Document: strategy-aware delete (file/suffix/directory/none), plus
|
|
61
|
+
* meta removal under `.contentrain/meta/{id}/{slug}/`.
|
|
62
|
+
*/
|
|
63
|
+
async function planContentDelete(reader, input) {
|
|
64
|
+
const { model } = input;
|
|
65
|
+
const changes = [];
|
|
66
|
+
const removed = [];
|
|
67
|
+
switch (model.kind) {
|
|
68
|
+
case "collection": {
|
|
69
|
+
if (!input.id) throw new Error("Collection delete requires an entry ID");
|
|
70
|
+
const locales = input.locale ? [input.locale] : await discoverCollectionLocales(reader, model);
|
|
71
|
+
for (const loc of locales) {
|
|
72
|
+
const cPath = contentFilePath(model, loc);
|
|
73
|
+
const data = await tryReadJson(reader, cPath);
|
|
74
|
+
if (!data || !(input.id in data)) continue;
|
|
75
|
+
delete data[input.id];
|
|
76
|
+
const sorted = {};
|
|
77
|
+
for (const k of Object.keys(data).toSorted()) sorted[k] = data[k];
|
|
78
|
+
changes.push({
|
|
79
|
+
path: cPath,
|
|
80
|
+
content: canonicalStringify$1(sorted)
|
|
81
|
+
});
|
|
82
|
+
removed.push(`content/${model.domain}/${model.id}/${loc}.json#${input.id}`);
|
|
83
|
+
}
|
|
84
|
+
for (const loc of locales) {
|
|
85
|
+
const mPath = metaFilePath(model, loc);
|
|
86
|
+
const meta = await tryReadJson(reader, mPath);
|
|
87
|
+
if (!meta || !(input.id in meta)) continue;
|
|
88
|
+
delete meta[input.id];
|
|
89
|
+
if (Object.keys(meta).length > 0) changes.push({
|
|
90
|
+
path: mPath,
|
|
91
|
+
content: canonicalStringify$1(meta)
|
|
92
|
+
});
|
|
93
|
+
else changes.push({
|
|
94
|
+
path: mPath,
|
|
95
|
+
content: null
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "singleton": {
|
|
101
|
+
if (model.i18n && !input.locale) throw new Error("Singleton delete requires a locale when i18n is enabled");
|
|
102
|
+
const locale = input.locale ?? "data";
|
|
103
|
+
const cPath = contentFilePath(model, locale);
|
|
104
|
+
changes.push({
|
|
105
|
+
path: cPath,
|
|
106
|
+
content: null
|
|
107
|
+
});
|
|
108
|
+
removed.push(model.i18n ? `content/${model.domain}/${model.id}/${locale}.json` : `content/${model.domain}/${model.id}/data.json`);
|
|
109
|
+
if (model.i18n) changes.push({
|
|
110
|
+
path: metaFilePath(model, locale),
|
|
111
|
+
content: null
|
|
112
|
+
});
|
|
113
|
+
else await pushAllMetaForModel(reader, model.id, changes);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case "dictionary": {
|
|
117
|
+
if (model.i18n && !input.locale) throw new Error("Dictionary delete requires a locale when i18n is enabled");
|
|
118
|
+
const locale = input.locale ?? "data";
|
|
119
|
+
const cPath = contentFilePath(model, locale);
|
|
120
|
+
if (input.keys?.length) {
|
|
121
|
+
const existing = await tryReadJson(reader, cPath) ?? {};
|
|
122
|
+
const notFound = [];
|
|
123
|
+
for (const key of input.keys) if (key in existing) delete existing[key];
|
|
124
|
+
else notFound.push(key);
|
|
125
|
+
if (notFound.length > 0) throw new Error(`Dictionary "${model.id}" (${locale}): keys not found — [${notFound.join(", ")}]`);
|
|
126
|
+
changes.push({
|
|
127
|
+
path: cPath,
|
|
128
|
+
content: canonicalStringify$1(existing)
|
|
129
|
+
});
|
|
130
|
+
removed.push(...input.keys.map((k) => `${model.id}/${locale}:${k}`));
|
|
131
|
+
} else {
|
|
132
|
+
changes.push({
|
|
133
|
+
path: cPath,
|
|
134
|
+
content: null
|
|
135
|
+
});
|
|
136
|
+
removed.push(model.i18n ? `content/${model.domain}/${model.id}/${locale}.json` : `content/${model.domain}/${model.id}/data.json`);
|
|
137
|
+
if (model.i18n) changes.push({
|
|
138
|
+
path: metaFilePath(model, locale),
|
|
139
|
+
content: null
|
|
140
|
+
});
|
|
141
|
+
else await pushAllMetaForModel(reader, model.id, changes);
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case "document": {
|
|
146
|
+
if (!input.slug) throw new Error("Document delete requires a slug");
|
|
147
|
+
const slugErr = validateSlug(input.slug);
|
|
148
|
+
if (slugErr) throw new Error(slugErr);
|
|
149
|
+
const dir = contentDir(model);
|
|
150
|
+
const strategy = model.locale_strategy ?? "file";
|
|
151
|
+
if (!model.i18n) changes.push({
|
|
152
|
+
path: `${dir}/${input.slug}.md`,
|
|
153
|
+
content: null
|
|
154
|
+
});
|
|
155
|
+
else if (strategy === "file") {
|
|
156
|
+
const slugDir = `${dir}/${input.slug}`;
|
|
157
|
+
const files = await reader.listDirectory(slugDir);
|
|
158
|
+
for (const f of files) changes.push({
|
|
159
|
+
path: `${slugDir}/${f}`,
|
|
160
|
+
content: null
|
|
161
|
+
});
|
|
162
|
+
} else if (input.locale) changes.push({
|
|
163
|
+
path: documentFilePath(model, input.locale, input.slug),
|
|
164
|
+
content: null
|
|
165
|
+
});
|
|
166
|
+
else if (strategy === "suffix") {
|
|
167
|
+
const files = await reader.listDirectory(dir);
|
|
168
|
+
for (const f of files) if (f.startsWith(`${input.slug}.`) && f.endsWith(".md")) changes.push({
|
|
169
|
+
path: `${dir}/${f}`,
|
|
170
|
+
content: null
|
|
171
|
+
});
|
|
172
|
+
} else if (strategy === "directory") {
|
|
173
|
+
const subdirs = await reader.listDirectory(dir);
|
|
174
|
+
for (const d of subdirs) {
|
|
175
|
+
const filePath = `${dir}/${d}/${input.slug}.md`;
|
|
176
|
+
if (await reader.fileExists(filePath)) changes.push({
|
|
177
|
+
path: filePath,
|
|
178
|
+
content: null
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} else if (strategy === "none") {
|
|
182
|
+
if ((await reader.listDirectory(dir)).includes(`${input.slug}.md`)) changes.push({
|
|
183
|
+
path: `${dir}/${input.slug}.md`,
|
|
184
|
+
content: null
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
removed.push(`${model.content_path ?? `content/${model.domain}/${model.id}`}/${input.slug}`);
|
|
188
|
+
const metaSlugDir = `.contentrain/meta/${model.id}/${input.slug}`;
|
|
189
|
+
if (input.locale) changes.push({
|
|
190
|
+
path: `${metaSlugDir}/${input.locale}.json`,
|
|
191
|
+
content: null
|
|
192
|
+
});
|
|
193
|
+
else {
|
|
194
|
+
const metaFiles = await reader.listDirectory(metaSlugDir);
|
|
195
|
+
for (const f of metaFiles) changes.push({
|
|
196
|
+
path: `${metaSlugDir}/${f}`,
|
|
197
|
+
content: null
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
changes.sort((a, b) => a.path.localeCompare(b.path));
|
|
204
|
+
return {
|
|
205
|
+
changes,
|
|
206
|
+
result: removed,
|
|
207
|
+
advisories: []
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async function pushAllMetaForModel(reader, modelId, changes) {
|
|
211
|
+
const metaDir = `.contentrain/meta/${modelId}`;
|
|
212
|
+
const files = await reader.listDirectory(metaDir);
|
|
213
|
+
for (const f of files) changes.push({
|
|
214
|
+
path: `${metaDir}/${f}`,
|
|
215
|
+
content: null
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/core/ops/content-save.ts
|
|
220
|
+
/**
|
|
221
|
+
* Build the FileChange[] required to save a batch of content entries. The
|
|
222
|
+
* plan is deterministic, pure, and does not touch disk — it reads existing
|
|
223
|
+
* state through `reader` and returns changes for a single atomic commit.
|
|
224
|
+
*
|
|
225
|
+
* Grouping: multiple entries targeting the same (model, locale) share a
|
|
226
|
+
* single content file and a single meta file. Reads are cached in-memory
|
|
227
|
+
* so repeated reads of the same path during a plan are one IO.
|
|
228
|
+
*/
|
|
229
|
+
async function planContentSave(reader, input) {
|
|
230
|
+
const { model, entries, config, vocabulary } = input;
|
|
231
|
+
const result = [];
|
|
232
|
+
const advisories = [];
|
|
233
|
+
const contentByPath = /* @__PURE__ */ new Map();
|
|
234
|
+
const metaByPath = /* @__PURE__ */ new Map();
|
|
235
|
+
const markdownChanges = /* @__PURE__ */ new Map();
|
|
236
|
+
async function readJsonOrEmpty(path) {
|
|
237
|
+
try {
|
|
238
|
+
return JSON.parse(await reader.readFile(path));
|
|
239
|
+
} catch {
|
|
240
|
+
return {};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const defaultLocale = config.locales.default;
|
|
244
|
+
for (const entry of entries) {
|
|
245
|
+
const locale = entry.locale ?? defaultLocale;
|
|
246
|
+
const localeErr = validateLocale(locale, config);
|
|
247
|
+
if (localeErr) throw new Error(localeErr);
|
|
248
|
+
if (entry.id) {
|
|
249
|
+
const err = validateEntryId(entry.id);
|
|
250
|
+
if (err) throw new Error(err);
|
|
251
|
+
}
|
|
252
|
+
if (entry.slug) {
|
|
253
|
+
const err = validateSlug(entry.slug);
|
|
254
|
+
if (err) throw new Error(err);
|
|
255
|
+
}
|
|
256
|
+
switch (model.kind) {
|
|
257
|
+
case "singleton": {
|
|
258
|
+
const cPath = contentFilePath(model, locale);
|
|
259
|
+
const mPath = metaFilePath(model, locale);
|
|
260
|
+
contentByPath.set(cPath, entry.data);
|
|
261
|
+
metaByPath.set(mPath, defaultMeta(entry.data));
|
|
262
|
+
result.push({
|
|
263
|
+
action: "updated",
|
|
264
|
+
locale
|
|
265
|
+
});
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
case "collection": {
|
|
269
|
+
const isNew = !entry.id;
|
|
270
|
+
const id = entry.id ?? generateEntryId();
|
|
271
|
+
const cPath = contentFilePath(model, locale);
|
|
272
|
+
const mPath = metaFilePath(model, locale);
|
|
273
|
+
const existing = contentByPath.get(cPath) ?? await readJsonOrEmpty(cPath);
|
|
274
|
+
const action = isNew || !(id in existing) ? "created" : "updated";
|
|
275
|
+
existing[id] = entry.data;
|
|
276
|
+
const sorted = {};
|
|
277
|
+
for (const key of Object.keys(existing).toSorted()) sorted[key] = existing[key];
|
|
278
|
+
contentByPath.set(cPath, sorted);
|
|
279
|
+
const existingMeta = metaByPath.get(mPath) ?? await readJsonOrEmpty(mPath);
|
|
280
|
+
existingMeta[id] = defaultMeta(entry.data);
|
|
281
|
+
metaByPath.set(mPath, existingMeta);
|
|
282
|
+
result.push({
|
|
283
|
+
action,
|
|
284
|
+
id,
|
|
285
|
+
locale
|
|
286
|
+
});
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
case "dictionary": {
|
|
290
|
+
const cPath = contentFilePath(model, locale);
|
|
291
|
+
const mPath = metaFilePath(model, locale);
|
|
292
|
+
const existing = contentByPath.get(cPath) ?? await readJsonOrEmpty(cPath);
|
|
293
|
+
const newData = entry.data;
|
|
294
|
+
const collisions = Object.keys(newData).filter((k) => k in existing && existing[k] !== newData[k]);
|
|
295
|
+
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.`);
|
|
296
|
+
const entryAdvisories = [];
|
|
297
|
+
const reverseMap = /* @__PURE__ */ new Map();
|
|
298
|
+
for (const [k, v] of Object.entries(existing)) reverseMap.set(v, k);
|
|
299
|
+
for (const [newKey, newValue] of Object.entries(newData)) {
|
|
300
|
+
if (newKey in existing) continue;
|
|
301
|
+
const existingKey = reverseMap.get(newValue);
|
|
302
|
+
if (existingKey && existingKey !== newKey) entryAdvisories.push(`Value "${newValue}" already exists as key "${existingKey}". Consider reusing instead of creating "${newKey}".`);
|
|
303
|
+
}
|
|
304
|
+
if (vocabulary && Object.keys(vocabulary.terms).length > 0) outer: for (const [newKey, newValue] of Object.entries(newData)) {
|
|
305
|
+
if (newKey in existing) continue;
|
|
306
|
+
for (const translations of Object.values(vocabulary.terms)) if (Object.values(translations).includes(newValue)) {
|
|
307
|
+
entryAdvisories.push(`Value "${newValue}" matches a vocabulary term. Use the canonical form for consistency.`);
|
|
308
|
+
continue outer;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
advisories.push(...entryAdvisories);
|
|
312
|
+
contentByPath.set(cPath, {
|
|
313
|
+
...existing,
|
|
314
|
+
...newData
|
|
315
|
+
});
|
|
316
|
+
metaByPath.set(mPath, defaultMeta(entry.data));
|
|
317
|
+
result.push({
|
|
318
|
+
action: "updated",
|
|
319
|
+
locale,
|
|
320
|
+
...entryAdvisories.length > 0 ? { advisories: entryAdvisories } : {}
|
|
321
|
+
});
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
case "document": {
|
|
325
|
+
const slug = entry.slug ?? entry.data["slug"];
|
|
326
|
+
if (!slug) throw new Error("Document entries require a slug");
|
|
327
|
+
const slugErr = validateSlug(slug);
|
|
328
|
+
if (slugErr) throw new Error(slugErr);
|
|
329
|
+
const fmData = { ...entry.data };
|
|
330
|
+
const bodyContent = fmData["body"] ?? "";
|
|
331
|
+
delete fmData["body"];
|
|
332
|
+
if (!fmData["slug"]) fmData["slug"] = slug;
|
|
333
|
+
const dPath = documentFilePath(model, locale, slug);
|
|
334
|
+
const mPath = metaFilePath(model, locale, slug);
|
|
335
|
+
let existingRaw = null;
|
|
336
|
+
try {
|
|
337
|
+
existingRaw = await reader.readFile(dPath);
|
|
338
|
+
} catch {}
|
|
339
|
+
const action = existingRaw ? "updated" : "created";
|
|
340
|
+
markdownChanges.set(dPath, serializeMarkdownFrontmatter$1(fmData, bodyContent));
|
|
341
|
+
metaByPath.set(mPath, defaultMeta(entry.data));
|
|
342
|
+
result.push({
|
|
343
|
+
action,
|
|
344
|
+
slug,
|
|
345
|
+
locale
|
|
346
|
+
});
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const changes = [];
|
|
352
|
+
for (const [path, data] of contentByPath) changes.push({
|
|
353
|
+
path,
|
|
354
|
+
content: canonicalStringify$1(data)
|
|
355
|
+
});
|
|
356
|
+
for (const [path, meta] of metaByPath) changes.push({
|
|
357
|
+
path,
|
|
358
|
+
content: canonicalStringify$1(meta)
|
|
359
|
+
});
|
|
360
|
+
for (const [path, content] of markdownChanges) changes.push({
|
|
361
|
+
path,
|
|
362
|
+
content
|
|
363
|
+
});
|
|
364
|
+
changes.sort((a, b) => a.path.localeCompare(b.path));
|
|
365
|
+
return {
|
|
366
|
+
changes,
|
|
367
|
+
result,
|
|
368
|
+
advisories
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function defaultMeta(data) {
|
|
372
|
+
const meta = {
|
|
373
|
+
status: "draft",
|
|
374
|
+
source: "agent",
|
|
375
|
+
updated_by: "contentrain-mcp"
|
|
376
|
+
};
|
|
377
|
+
if (data?.["publish_at"] !== void 0) meta.publish_at = data["publish_at"];
|
|
378
|
+
if (data?.["expire_at"] !== void 0) meta.expire_at = data["expire_at"];
|
|
379
|
+
return meta;
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/core/ops/model-delete.ts
|
|
383
|
+
/**
|
|
384
|
+
* Build the FileChange[] required to delete a model and its associated
|
|
385
|
+
* content/meta files.
|
|
386
|
+
*
|
|
387
|
+
* The schema is at most two directory levels deep (e.g. document meta at
|
|
388
|
+
* `.contentrain/meta/{id}/{slug}/{locale}.json`). The helper below walks
|
|
389
|
+
* that much, which is enough for every model kind + locale strategy and
|
|
390
|
+
* keeps us free of a general-purpose recursive lister for now.
|
|
391
|
+
*
|
|
392
|
+
* `result` is the legacy three-entry summary:
|
|
393
|
+
* - `models/{id}.json`
|
|
394
|
+
* - `content/{domain}/{id}/` (only if any content files existed)
|
|
395
|
+
* - `meta/{id}/` (only if any meta files existed)
|
|
396
|
+
*/
|
|
397
|
+
async function planModelDelete(reader, input) {
|
|
398
|
+
const { model } = input;
|
|
399
|
+
const changes = [];
|
|
400
|
+
const removed = [];
|
|
401
|
+
changes.push({
|
|
402
|
+
path: `.contentrain/models/${model.id}.json`,
|
|
403
|
+
content: null
|
|
404
|
+
});
|
|
405
|
+
removed.push(`models/${model.id}.json`);
|
|
406
|
+
const contentFiles = await enumerateTwoLevels(reader, model.content_path ?? `.contentrain/content/${model.domain}/${model.id}`);
|
|
407
|
+
for (const path of contentFiles) changes.push({
|
|
408
|
+
path,
|
|
409
|
+
content: null
|
|
410
|
+
});
|
|
411
|
+
if (contentFiles.length > 0) removed.push(model.content_path ?? `content/${model.domain}/${model.id}/`);
|
|
412
|
+
const metaFiles = await enumerateTwoLevels(reader, `.contentrain/meta/${model.id}`);
|
|
413
|
+
for (const path of metaFiles) changes.push({
|
|
414
|
+
path,
|
|
415
|
+
content: null
|
|
416
|
+
});
|
|
417
|
+
if (metaFiles.length > 0) removed.push(`meta/${model.id}/`);
|
|
418
|
+
changes.sort((a, b) => a.path.localeCompare(b.path));
|
|
419
|
+
return {
|
|
420
|
+
changes,
|
|
421
|
+
result: removed,
|
|
422
|
+
advisories: []
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/** List files up to two directory levels deep. Filters to `.json` and `.md`. */
|
|
426
|
+
async function enumerateTwoLevels(reader, dir) {
|
|
427
|
+
const results = [];
|
|
428
|
+
const entries = await reader.listDirectory(dir);
|
|
429
|
+
for (const entry of entries) {
|
|
430
|
+
const path = `${dir}/${entry}`;
|
|
431
|
+
if (entry.endsWith(".json") || entry.endsWith(".md")) {
|
|
432
|
+
results.push(path);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const subEntries = await reader.listDirectory(path);
|
|
436
|
+
for (const sub of subEntries) if (sub.endsWith(".json") || sub.endsWith(".md")) results.push(`${path}/${sub}`);
|
|
437
|
+
}
|
|
438
|
+
return results;
|
|
439
|
+
}
|
|
440
|
+
//#endregion
|
|
441
|
+
//#region src/core/ops/model-save.ts
|
|
442
|
+
/** Field order for canonical model JSON — preserves reader ergonomics. */
|
|
443
|
+
const MODEL_FIELD_ORDER = [
|
|
444
|
+
"id",
|
|
445
|
+
"name",
|
|
446
|
+
"kind",
|
|
447
|
+
"domain",
|
|
448
|
+
"i18n",
|
|
449
|
+
"description",
|
|
450
|
+
"content_path",
|
|
451
|
+
"locale_strategy",
|
|
452
|
+
"fields"
|
|
453
|
+
];
|
|
454
|
+
/**
|
|
455
|
+
* Build the FileChange[] required to create or update a model definition.
|
|
456
|
+
*
|
|
457
|
+
* The only disk change is the model JSON file itself. Content and meta
|
|
458
|
+
* directories are created on-demand when their first write lands — git does
|
|
459
|
+
* not track empty directories, and MCP's write helpers `mkdir -p` before
|
|
460
|
+
* every file write, so there is no operational reason to emit an empty-dir
|
|
461
|
+
* ensure step here.
|
|
462
|
+
*/
|
|
463
|
+
async function planModelSave(reader, input) {
|
|
464
|
+
const { model } = input;
|
|
465
|
+
const modelPath = `.contentrain/models/${model.id}.json`;
|
|
466
|
+
let action = "created";
|
|
467
|
+
try {
|
|
468
|
+
await reader.readFile(modelPath);
|
|
469
|
+
action = "updated";
|
|
470
|
+
} catch {}
|
|
471
|
+
return {
|
|
472
|
+
changes: [{
|
|
473
|
+
path: modelPath,
|
|
474
|
+
content: canonicalStringify$1(model, MODEL_FIELD_ORDER)
|
|
475
|
+
}],
|
|
476
|
+
result: {
|
|
477
|
+
action,
|
|
478
|
+
id: model.id
|
|
479
|
+
},
|
|
480
|
+
advisories: []
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
//#endregion
|
|
484
|
+
export { applyChangesToWorktree as a, planContentDelete as i, planModelDelete as n, planContentSave as r, planModelSave as t };
|
|
485
|
+
|
|
486
|
+
//# sourceMappingURL=ops-DjEWi5Wa.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ops-DjEWi5Wa.mjs","names":["canonicalStringify","serializeMarkdownFrontmatter","canonicalStringify","canonicalStringify"],"sources":["../src/core/ops/apply-to-worktree.ts","../src/core/ops/content-delete.ts","../src/core/ops/content-save.ts","../src/core/ops/model-delete.ts","../src/core/ops/model-save.ts"],"sourcesContent":["import { mkdir, rm, writeFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport type { FileChange } from '../contracts/index.js'\n\n/**\n * Phase 2 shim — applies a `FileChange[]` to a local worktree directory.\n *\n * Phase 3 replaces this with `LocalProvider.applyPlan` (same logic, owned by\n * the provider) while a `GitHubProvider.applyPlan` produces the equivalent\n * commit through the Git Data API. Until then, tool handlers call this shim\n * inside the existing `createTransaction` flow so Phase 2 gets plan/apply\n * semantics without rewriting the transaction machinery.\n *\n * - `content: string` → `writeFile` (creates parent dirs).\n * - `content: null` → `rm`; missing files are tolerated.\n * - Changes are applied in their given order; callers should sort for\n * determinism.\n */\nexport async function applyChangesToWorktree(worktree: string, changes: readonly FileChange[]): Promise<void> {\n for (const change of changes) {\n const absolute = join(worktree, change.path)\n if (change.content === null) {\n await rm(absolute, { force: true }).catch(() => { /* tolerate missing */ })\n continue\n }\n await mkdir(dirname(absolute), { recursive: true })\n await writeFile(absolute, change.content, 'utf-8')\n }\n}\n","import type { EntryMeta, ModelDefinition } from '@contentrain/types'\nimport { validateSlug } from '@contentrain/types'\nimport type { FileChange, RepoReader } from '../contracts/index.js'\nimport { canonicalStringify } from '../serialization/index.js'\nimport { contentFilePath, documentFilePath, metaFilePath } from './paths.js'\nimport type { OpPlan } from './types.js'\n\nexport interface ContentDeleteInput {\n model: ModelDefinition\n id?: string\n slug?: string\n locale?: string\n keys?: string[]\n}\n\nexport type ContentDeletePlan = OpPlan<string[]>\n\nfunction contentDir(model: Pick<ModelDefinition, 'domain' | 'id' | 'content_path'>): string {\n return model.content_path ?? `.contentrain/content/${model.domain}/${model.id}`\n}\n\nasync function tryReadJson<T>(reader: RepoReader, path: string): Promise<T | null> {\n try {\n return JSON.parse(await reader.readFile(path)) as T\n } catch {\n return null\n }\n}\n\nasync function discoverCollectionLocales(\n reader: RepoReader,\n model: ModelDefinition,\n): Promise<string[]> {\n const dir = contentDir(model)\n const files = await reader.listDirectory(dir)\n const strategy = model.locale_strategy ?? 'file'\n if (strategy === 'directory') {\n return files\n }\n return files\n .filter(f => f.endsWith('.json'))\n .map(f => f.replace('.json', '').replace(`${model.id}.`, ''))\n}\n\n/**\n * Build the FileChange[] required to delete content. Pure — touches no disk.\n *\n * Semantics preserved from legacy `deleteContent`:\n * - Collection: remove the entry ID from each locale's content file and its\n * meta file. Meta file is deleted outright if the removal empties it.\n * - Singleton: delete the locale's content + meta file (locale-scoped when\n * i18n, non-i18n removes whatever meta lives under `.contentrain/meta/{id}/`).\n * - Dictionary: two modes — key-scoped removal updates the file in place,\n * or locale-scoped removes the content + meta pair.\n * - Document: strategy-aware delete (file/suffix/directory/none), plus\n * meta removal under `.contentrain/meta/{id}/{slug}/`.\n */\nexport async function planContentDelete(\n reader: RepoReader,\n input: ContentDeleteInput,\n): Promise<ContentDeletePlan> {\n const { model } = input\n const changes: FileChange[] = []\n const removed: string[] = []\n\n switch (model.kind) {\n case 'collection': {\n if (!input.id) throw new Error('Collection delete requires an entry ID')\n\n const locales = input.locale\n ? [input.locale]\n : await discoverCollectionLocales(reader, model)\n\n for (const loc of locales) {\n const cPath = contentFilePath(model, loc)\n const data = await tryReadJson<Record<string, unknown>>(reader, cPath)\n if (!data || !(input.id in data)) continue\n delete data[input.id]\n const sorted: Record<string, unknown> = {}\n for (const k of Object.keys(data).toSorted()) sorted[k] = data[k]\n changes.push({ path: cPath, content: canonicalStringify(sorted) })\n removed.push(`content/${model.domain}/${model.id}/${loc}.json#${input.id}`)\n }\n\n for (const loc of locales) {\n const mPath = metaFilePath(model, loc)\n const meta = await tryReadJson<Record<string, EntryMeta>>(reader, mPath)\n if (!meta || !(input.id in meta)) continue\n delete meta[input.id]\n if (Object.keys(meta).length > 0) {\n changes.push({ path: mPath, content: canonicalStringify(meta) })\n } else {\n changes.push({ path: mPath, content: null })\n }\n }\n break\n }\n\n case 'singleton': {\n if (model.i18n && !input.locale) {\n throw new Error('Singleton delete requires a locale when i18n is enabled')\n }\n const locale = input.locale ?? 'data'\n const cPath = contentFilePath(model, locale)\n changes.push({ path: cPath, content: null })\n removed.push(model.i18n\n ? `content/${model.domain}/${model.id}/${locale}.json`\n : `content/${model.domain}/${model.id}/data.json`)\n\n if (model.i18n) {\n changes.push({ path: metaFilePath(model, locale), content: null })\n } else {\n await pushAllMetaForModel(reader, model.id, changes)\n }\n break\n }\n\n case 'dictionary': {\n if (model.i18n && !input.locale) {\n throw new Error('Dictionary delete requires a locale when i18n is enabled')\n }\n const locale = input.locale ?? 'data'\n const cPath = contentFilePath(model, locale)\n\n if (input.keys?.length) {\n const existing = (await tryReadJson<Record<string, string>>(reader, cPath)) ?? {}\n const notFound: string[] = []\n for (const key of input.keys) {\n if (key in existing) delete existing[key]\n else notFound.push(key)\n }\n if (notFound.length > 0) {\n throw new Error(`Dictionary \"${model.id}\" (${locale}): keys not found — [${notFound.join(', ')}]`)\n }\n changes.push({ path: cPath, content: canonicalStringify(existing) })\n removed.push(...input.keys.map(k => `${model.id}/${locale}:${k}`))\n } else {\n changes.push({ path: cPath, content: null })\n removed.push(model.i18n\n ? `content/${model.domain}/${model.id}/${locale}.json`\n : `content/${model.domain}/${model.id}/data.json`)\n if (model.i18n) {\n changes.push({ path: metaFilePath(model, locale), content: null })\n } else {\n await pushAllMetaForModel(reader, model.id, changes)\n }\n }\n break\n }\n\n case 'document': {\n if (!input.slug) throw new Error('Document delete requires a slug')\n const slugErr = validateSlug(input.slug)\n if (slugErr) throw new Error(slugErr)\n\n const dir = contentDir(model)\n const strategy = model.locale_strategy ?? 'file'\n\n if (!model.i18n) {\n changes.push({ path: `${dir}/${input.slug}.md`, content: null })\n } else if (strategy === 'file') {\n const slugDir = `${dir}/${input.slug}`\n const files = await reader.listDirectory(slugDir)\n for (const f of files) changes.push({ path: `${slugDir}/${f}`, content: null })\n } else if (input.locale) {\n changes.push({ path: documentFilePath(model, input.locale, input.slug), content: null })\n } else if (strategy === 'suffix') {\n const files = await reader.listDirectory(dir)\n for (const f of files) {\n if (f.startsWith(`${input.slug}.`) && f.endsWith('.md')) {\n changes.push({ path: `${dir}/${f}`, content: null })\n }\n }\n } else if (strategy === 'directory') {\n const subdirs = await reader.listDirectory(dir)\n for (const d of subdirs) {\n const filePath = `${dir}/${d}/${input.slug}.md`\n if (await reader.fileExists(filePath)) {\n changes.push({ path: filePath, content: null })\n }\n }\n } else if (strategy === 'none') {\n const files = await reader.listDirectory(dir)\n if (files.includes(`${input.slug}.md`)) {\n changes.push({ path: `${dir}/${input.slug}.md`, content: null })\n }\n }\n\n removed.push(`${model.content_path ?? `content/${model.domain}/${model.id}`}/${input.slug}`)\n\n const metaSlugDir = `.contentrain/meta/${model.id}/${input.slug}`\n if (input.locale) {\n changes.push({ path: `${metaSlugDir}/${input.locale}.json`, content: null })\n } else {\n const metaFiles = await reader.listDirectory(metaSlugDir)\n for (const f of metaFiles) changes.push({ path: `${metaSlugDir}/${f}`, content: null })\n }\n break\n }\n }\n\n changes.sort((a, b) => a.path.localeCompare(b.path))\n return { changes, result: removed, advisories: [] }\n}\n\nasync function pushAllMetaForModel(reader: RepoReader, modelId: string, changes: FileChange[]): Promise<void> {\n const metaDir = `.contentrain/meta/${modelId}`\n const files = await reader.listDirectory(metaDir)\n for (const f of files) changes.push({ path: `${metaDir}/${f}`, content: null })\n}\n","import type { ContentrainConfig, EntryMeta, ModelDefinition, Vocabulary } from '@contentrain/types'\nimport { generateEntryId, validateEntryId, validateLocale, validateSlug } from '@contentrain/types'\nimport type { FileChange, RepoReader } from '../contracts/index.js'\nimport type { ContentEntry } from '../content-manager.js'\nimport { canonicalStringify, serializeMarkdownFrontmatter } from '../serialization/index.js'\nimport type { ContentSaveEntryResult, ContentSavePlan } from './types.js'\nimport { contentFilePath, documentFilePath, metaFilePath } from './paths.js'\n\ninterface PlanInput {\n model: ModelDefinition\n entries: ContentEntry[]\n config: ContentrainConfig\n vocabulary?: Vocabulary | null\n}\n\n/**\n * Build the FileChange[] required to save a batch of content entries. The\n * plan is deterministic, pure, and does not touch disk — it reads existing\n * state through `reader` and returns changes for a single atomic commit.\n *\n * Grouping: multiple entries targeting the same (model, locale) share a\n * single content file and a single meta file. Reads are cached in-memory\n * so repeated reads of the same path during a plan are one IO.\n */\nexport async function planContentSave(reader: RepoReader, input: PlanInput): Promise<ContentSavePlan> {\n const { model, entries, config, vocabulary } = input\n const result: ContentSaveEntryResult[] = []\n const advisories: string[] = []\n\n // In-memory accumulators keyed by content-root-relative path.\n const contentByPath = new Map<string, unknown>()\n const metaByPath = new Map<string, unknown>()\n const markdownChanges = new Map<string, string>()\n\n async function readJsonOrEmpty<T>(path: string): Promise<T> {\n try {\n return JSON.parse(await reader.readFile(path)) as T\n } catch {\n return {} as T\n }\n }\n\n const defaultLocale = config.locales.default\n\n for (const entry of entries) {\n const locale = entry.locale ?? defaultLocale\n\n const localeErr = validateLocale(locale, config)\n if (localeErr) throw new Error(localeErr)\n if (entry.id) {\n const err = validateEntryId(entry.id)\n if (err) throw new Error(err)\n }\n if (entry.slug) {\n const err = validateSlug(entry.slug)\n if (err) throw new Error(err)\n }\n\n switch (model.kind) {\n case 'singleton': {\n const cPath = contentFilePath(model, locale)\n const mPath = metaFilePath(model, locale)\n contentByPath.set(cPath, entry.data)\n metaByPath.set(mPath, defaultMeta(entry.data))\n result.push({ action: 'updated', locale })\n break\n }\n\n case 'collection': {\n const isNew = !entry.id\n const id = entry.id ?? generateEntryId()\n const cPath = contentFilePath(model, locale)\n const mPath = metaFilePath(model, locale)\n\n const existing = (contentByPath.get(cPath) as Record<string, unknown> | undefined)\n ?? await readJsonOrEmpty<Record<string, unknown>>(cPath)\n\n const action: 'created' | 'updated' = isNew || !(id in existing) ? 'created' : 'updated'\n existing[id] = entry.data\n\n const sorted: Record<string, unknown> = {}\n for (const key of Object.keys(existing).toSorted()) {\n sorted[key] = existing[key]\n }\n contentByPath.set(cPath, sorted)\n\n const existingMeta = (metaByPath.get(mPath) as Record<string, EntryMeta> | undefined)\n ?? await readJsonOrEmpty<Record<string, EntryMeta>>(mPath)\n existingMeta[id] = defaultMeta(entry.data)\n metaByPath.set(mPath, existingMeta)\n\n result.push({ action, id, locale })\n break\n }\n\n case 'dictionary': {\n const cPath = contentFilePath(model, locale)\n const mPath = metaFilePath(model, locale)\n\n const existing = (contentByPath.get(cPath) as Record<string, string> | undefined)\n ?? await readJsonOrEmpty<Record<string, string>>(cPath)\n\n const newData = entry.data as Record<string, string>\n const collisions = Object.keys(newData).filter(\n k => k in existing && existing[k] !== newData[k],\n )\n if (collisions.length > 0) {\n throw new Error(\n `Dictionary \"${model.id}\" (${locale}): ${collisions.length} key collision(s) — [${collisions.join(', ')}] already exist with different values. `\n + 'Read existing keys with contentrain_content_list first, or include all keys in a single save call.',\n )\n }\n\n const entryAdvisories: string[] = []\n const reverseMap = new Map<string, string>()\n for (const [k, v] of Object.entries(existing)) reverseMap.set(v, k)\n for (const [newKey, newValue] of Object.entries(newData)) {\n if (newKey in existing) continue\n const existingKey = reverseMap.get(newValue)\n if (existingKey && existingKey !== newKey) {\n entryAdvisories.push(\n `Value \"${newValue}\" already exists as key \"${existingKey}\". Consider reusing instead of creating \"${newKey}\".`,\n )\n }\n }\n\n if (vocabulary && Object.keys(vocabulary.terms).length > 0) {\n outer:\n for (const [newKey, newValue] of Object.entries(newData)) {\n if (newKey in existing) continue\n for (const translations of Object.values(vocabulary.terms)) {\n if (Object.values(translations).includes(newValue)) {\n entryAdvisories.push(\n `Value \"${newValue}\" matches a vocabulary term. Use the canonical form for consistency.`,\n )\n continue outer\n }\n }\n }\n }\n\n advisories.push(...entryAdvisories)\n\n contentByPath.set(cPath, { ...existing, ...newData })\n metaByPath.set(mPath, defaultMeta(entry.data))\n\n result.push({\n action: 'updated',\n locale,\n ...(entryAdvisories.length > 0 ? { advisories: entryAdvisories } : {}),\n })\n break\n }\n\n case 'document': {\n const slug = entry.slug ?? (entry.data['slug'] as string | undefined)\n if (!slug) throw new Error('Document entries require a slug')\n const slugErr = validateSlug(slug)\n if (slugErr) throw new Error(slugErr)\n\n const fmData = { ...entry.data }\n const bodyContent = (fmData['body'] as string | undefined) ?? ''\n delete fmData['body']\n if (!fmData['slug']) fmData['slug'] = slug\n\n const dPath = documentFilePath(model, locale, slug)\n const mPath = metaFilePath(model, locale, slug)\n\n let existingRaw: string | null = null\n try { existingRaw = await reader.readFile(dPath) }\n catch { /* not yet */ }\n const action: 'created' | 'updated' = existingRaw ? 'updated' : 'created'\n\n markdownChanges.set(dPath, serializeMarkdownFrontmatter(fmData, bodyContent))\n metaByPath.set(mPath, defaultMeta(entry.data))\n\n result.push({ action, slug, locale })\n break\n }\n }\n }\n\n const changes: FileChange[] = []\n for (const [path, data] of contentByPath) {\n changes.push({ path, content: canonicalStringify(data) })\n }\n for (const [path, meta] of metaByPath) {\n changes.push({ path, content: canonicalStringify(meta) })\n }\n for (const [path, content] of markdownChanges) {\n changes.push({ path, content })\n }\n changes.sort((a, b) => a.path.localeCompare(b.path))\n\n return { changes, result, advisories }\n}\n\nfunction defaultMeta(data?: Record<string, unknown>): EntryMeta {\n const meta: EntryMeta = {\n status: 'draft',\n source: 'agent',\n updated_by: 'contentrain-mcp',\n }\n if (data?.['publish_at'] !== undefined) meta.publish_at = data['publish_at'] as string\n if (data?.['expire_at'] !== undefined) meta.expire_at = data['expire_at'] as string\n return meta\n}\n","import type { ModelDefinition } from '@contentrain/types'\nimport type { FileChange, RepoReader } from '../contracts/index.js'\nimport type { OpPlan } from './types.js'\n\nexport interface ModelDeleteInput {\n model: ModelDefinition\n}\n\nexport type ModelDeletePlan = OpPlan<string[]>\n\n/**\n * Build the FileChange[] required to delete a model and its associated\n * content/meta files.\n *\n * The schema is at most two directory levels deep (e.g. document meta at\n * `.contentrain/meta/{id}/{slug}/{locale}.json`). The helper below walks\n * that much, which is enough for every model kind + locale strategy and\n * keeps us free of a general-purpose recursive lister for now.\n *\n * `result` is the legacy three-entry summary:\n * - `models/{id}.json`\n * - `content/{domain}/{id}/` (only if any content files existed)\n * - `meta/{id}/` (only if any meta files existed)\n */\nexport async function planModelDelete(reader: RepoReader, input: ModelDeleteInput): Promise<ModelDeletePlan> {\n const { model } = input\n const changes: FileChange[] = []\n const removed: string[] = []\n\n changes.push({ path: `.contentrain/models/${model.id}.json`, content: null })\n removed.push(`models/${model.id}.json`)\n\n const contentDir = model.content_path ?? `.contentrain/content/${model.domain}/${model.id}`\n const contentFiles = await enumerateTwoLevels(reader, contentDir)\n for (const path of contentFiles) {\n changes.push({ path, content: null })\n }\n if (contentFiles.length > 0) {\n removed.push(model.content_path ?? `content/${model.domain}/${model.id}/`)\n }\n\n const metaDir = `.contentrain/meta/${model.id}`\n const metaFiles = await enumerateTwoLevels(reader, metaDir)\n for (const path of metaFiles) {\n changes.push({ path, content: null })\n }\n if (metaFiles.length > 0) {\n removed.push(`meta/${model.id}/`)\n }\n\n changes.sort((a, b) => a.path.localeCompare(b.path))\n return { changes, result: removed, advisories: [] }\n}\n\n/** List files up to two directory levels deep. Filters to `.json` and `.md`. */\nasync function enumerateTwoLevels(reader: RepoReader, dir: string): Promise<string[]> {\n const results: string[] = []\n const entries = await reader.listDirectory(dir)\n for (const entry of entries) {\n const path = `${dir}/${entry}`\n if (entry.endsWith('.json') || entry.endsWith('.md')) {\n results.push(path)\n continue\n }\n const subEntries = await reader.listDirectory(path)\n for (const sub of subEntries) {\n if (sub.endsWith('.json') || sub.endsWith('.md')) {\n results.push(`${path}/${sub}`)\n }\n }\n }\n return results\n}\n","import type { ModelDefinition } from '@contentrain/types'\nimport type { FileChange, RepoReader } from '../contracts/index.js'\nimport { canonicalStringify } from '../serialization/index.js'\nimport type { OpPlan } from './types.js'\n\n/** Field order for canonical model JSON — preserves reader ergonomics. */\nconst MODEL_FIELD_ORDER = [\n 'id',\n 'name',\n 'kind',\n 'domain',\n 'i18n',\n 'description',\n 'content_path',\n 'locale_strategy',\n 'fields',\n]\n\nexport interface ModelSaveInput {\n model: ModelDefinition\n}\n\nexport interface ModelSaveResult {\n action: 'created' | 'updated'\n id: string\n}\n\nexport type ModelSavePlan = OpPlan<ModelSaveResult>\n\n/**\n * Build the FileChange[] required to create or update a model definition.\n *\n * The only disk change is the model JSON file itself. Content and meta\n * directories are created on-demand when their first write lands — git does\n * not track empty directories, and MCP's write helpers `mkdir -p` before\n * every file write, so there is no operational reason to emit an empty-dir\n * ensure step here.\n */\nexport async function planModelSave(reader: RepoReader, input: ModelSaveInput): Promise<ModelSavePlan> {\n const { model } = input\n const modelPath = `.contentrain/models/${model.id}.json`\n\n let action: 'created' | 'updated' = 'created'\n try {\n await reader.readFile(modelPath)\n action = 'updated'\n } catch { /* not yet exists */ }\n\n const changes: FileChange[] = [\n { path: modelPath, content: canonicalStringify(model, MODEL_FIELD_ORDER) },\n ]\n\n return {\n changes,\n result: { action, id: model.id },\n advisories: [],\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
//#region src/core/overlay-reader.ts
|
|
2
|
+
/**
|
|
3
|
+
* A RepoReader that overlays a set of pending FileChanges on top of an
|
|
4
|
+
* underlying reader. Used in the remote-provider write path so helpers
|
|
5
|
+
* like {@link import('./context.js').buildContextChange} and
|
|
6
|
+
* {@link import('./validator/project.js').validateProject} see the
|
|
7
|
+
* post-change state — the state the pending commit is about to produce
|
|
8
|
+
* — rather than the pre-change base branch.
|
|
9
|
+
*
|
|
10
|
+
* Semantics:
|
|
11
|
+
*
|
|
12
|
+
* - `readFile(path)` — returns pending `content` when the overlay maps
|
|
13
|
+
* the path; falls back to the base reader otherwise. A pending delete
|
|
14
|
+
* (`content: null`) surfaces as "missing" (throws, matching
|
|
15
|
+
* `RepoReader.readFile`'s missing-file contract).
|
|
16
|
+
*
|
|
17
|
+
* - `listDirectory(path)` — merges the base directory listing with
|
|
18
|
+
* pending additions that live directly in the same folder, removes
|
|
19
|
+
* entries whose pending change is a delete, and de-duplicates the
|
|
20
|
+
* result. Pending paths in nested subdirectories surface only at
|
|
21
|
+
* their own listings.
|
|
22
|
+
*
|
|
23
|
+
* - `fileExists(path)` — pending adds → `true`, pending deletes →
|
|
24
|
+
* `false`, otherwise delegates.
|
|
25
|
+
*
|
|
26
|
+
* The overlay keys are canonicalised to match the FileChange contract:
|
|
27
|
+
* forward slashes, no leading `/`, no `..` segments (FileChanges are
|
|
28
|
+
* required to respect these invariants).
|
|
29
|
+
*/
|
|
30
|
+
var OverlayReader = class {
|
|
31
|
+
overlay;
|
|
32
|
+
constructor(base, pendingChanges) {
|
|
33
|
+
this.base = base;
|
|
34
|
+
this.overlay = /* @__PURE__ */ new Map();
|
|
35
|
+
for (const change of pendingChanges) this.overlay.set(normalise(change.path), change);
|
|
36
|
+
}
|
|
37
|
+
async readFile(path, ref) {
|
|
38
|
+
const key = normalise(path);
|
|
39
|
+
const pending = this.overlay.get(key);
|
|
40
|
+
if (pending) {
|
|
41
|
+
if (pending.content === null) throw new Error(`OverlayReader: "${path}" is marked for deletion`);
|
|
42
|
+
return pending.content;
|
|
43
|
+
}
|
|
44
|
+
return this.base.readFile(path, ref);
|
|
45
|
+
}
|
|
46
|
+
async listDirectory(path, ref) {
|
|
47
|
+
const baseEntries = await this.base.listDirectory(path, ref);
|
|
48
|
+
const dirKey = normalise(path);
|
|
49
|
+
const prefix = dirKey === "" ? "" : `${dirKey}/`;
|
|
50
|
+
const direct = [...this.overlay.entries()].filter(([key]) => key.startsWith(prefix)).map(([key, change]) => ({
|
|
51
|
+
name: key.slice(prefix.length).split("/")[0] ?? "",
|
|
52
|
+
isNested: key.slice(prefix.length).includes("/"),
|
|
53
|
+
deleted: change.content === null
|
|
54
|
+
})).filter((entry) => entry.name.length > 0);
|
|
55
|
+
const deleted = new Set(direct.filter((e) => e.deleted && !e.isNested).map((e) => e.name));
|
|
56
|
+
const added = direct.filter((e) => !e.deleted).map((e) => e.name);
|
|
57
|
+
const result = baseEntries.filter((n) => !deleted.has(n));
|
|
58
|
+
for (const name of added) if (!result.includes(name)) result.push(name);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
async fileExists(path, ref) {
|
|
62
|
+
const key = normalise(path);
|
|
63
|
+
const pending = this.overlay.get(key);
|
|
64
|
+
if (pending) return pending.content !== null;
|
|
65
|
+
return this.base.fileExists(path, ref);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
function normalise(path) {
|
|
69
|
+
return path.replace(/^\/+/, "");
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
export { OverlayReader as t };
|
|
73
|
+
|
|
74
|
+
//# sourceMappingURL=overlay-reader-DOdyDRIv.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"overlay-reader-DOdyDRIv.mjs","names":[],"sources":["../src/core/overlay-reader.ts"],"sourcesContent":["import type { FileChange, RepoReader } from './contracts/index.js'\n\n/**\n * A RepoReader that overlays a set of pending FileChanges on top of an\n * underlying reader. Used in the remote-provider write path so helpers\n * like {@link import('./context.js').buildContextChange} and\n * {@link import('./validator/project.js').validateProject} see the\n * post-change state — the state the pending commit is about to produce\n * — rather than the pre-change base branch.\n *\n * Semantics:\n *\n * - `readFile(path)` — returns pending `content` when the overlay maps\n * the path; falls back to the base reader otherwise. A pending delete\n * (`content: null`) surfaces as \"missing\" (throws, matching\n * `RepoReader.readFile`'s missing-file contract).\n *\n * - `listDirectory(path)` — merges the base directory listing with\n * pending additions that live directly in the same folder, removes\n * entries whose pending change is a delete, and de-duplicates the\n * result. Pending paths in nested subdirectories surface only at\n * their own listings.\n *\n * - `fileExists(path)` — pending adds → `true`, pending deletes →\n * `false`, otherwise delegates.\n *\n * The overlay keys are canonicalised to match the FileChange contract:\n * forward slashes, no leading `/`, no `..` segments (FileChanges are\n * required to respect these invariants).\n */\nexport class OverlayReader implements RepoReader {\n private readonly overlay: Map<string, FileChange>\n\n constructor(\n private readonly base: RepoReader,\n pendingChanges: FileChange[],\n ) {\n this.overlay = new Map()\n for (const change of pendingChanges) {\n this.overlay.set(normalise(change.path), change)\n }\n }\n\n async readFile(path: string, ref?: string): Promise<string> {\n const key = normalise(path)\n const pending = this.overlay.get(key)\n if (pending) {\n if (pending.content === null) {\n throw new Error(`OverlayReader: \"${path}\" is marked for deletion`)\n }\n return pending.content\n }\n return this.base.readFile(path, ref)\n }\n\n async listDirectory(path: string, ref?: string): Promise<string[]> {\n const baseEntries = await this.base.listDirectory(path, ref)\n const dirKey = normalise(path)\n const prefix = dirKey === '' ? '' : `${dirKey}/`\n\n const direct = [...this.overlay.entries()]\n .filter(([key]) => key.startsWith(prefix))\n .map(([key, change]) => ({\n name: key.slice(prefix.length).split('/')[0] ?? '',\n isNested: key.slice(prefix.length).includes('/'),\n deleted: change.content === null,\n }))\n .filter(entry => entry.name.length > 0)\n\n // For nested pending paths (e.g. overlay at `dir/sub/a.json` when\n // listing `dir`), the immediate child directory `sub` must surface\n // even though no pending change targets it directly.\n const deleted = new Set(\n direct.filter(e => e.deleted && !e.isNested).map(e => e.name),\n )\n const added = direct\n .filter(e => !e.deleted)\n .map(e => e.name)\n\n const result = baseEntries.filter(n => !deleted.has(n))\n for (const name of added) {\n if (!result.includes(name)) result.push(name)\n }\n return result\n }\n\n async fileExists(path: string, ref?: string): Promise<boolean> {\n const key = normalise(path)\n const pending = this.overlay.get(key)\n if (pending) return pending.content !== null\n return this.base.fileExists(path, ref)\n }\n}\n\nfunction normalise(path: string): string {\n return path.replace(/^\\/+/, '')\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,IAAa,gBAAb,MAAiD;CAC/C;CAEA,YACE,MACA,gBACA;AAFiB,OAAA,OAAA;AAGjB,OAAK,0BAAU,IAAI,KAAK;AACxB,OAAK,MAAM,UAAU,eACnB,MAAK,QAAQ,IAAI,UAAU,OAAO,KAAK,EAAE,OAAO;;CAIpD,MAAM,SAAS,MAAc,KAA+B;EAC1D,MAAM,MAAM,UAAU,KAAK;EAC3B,MAAM,UAAU,KAAK,QAAQ,IAAI,IAAI;AACrC,MAAI,SAAS;AACX,OAAI,QAAQ,YAAY,KACtB,OAAM,IAAI,MAAM,mBAAmB,KAAK,0BAA0B;AAEpE,UAAO,QAAQ;;AAEjB,SAAO,KAAK,KAAK,SAAS,MAAM,IAAI;;CAGtC,MAAM,cAAc,MAAc,KAAiC;EACjE,MAAM,cAAc,MAAM,KAAK,KAAK,cAAc,MAAM,IAAI;EAC5D,MAAM,SAAS,UAAU,KAAK;EAC9B,MAAM,SAAS,WAAW,KAAK,KAAK,GAAG,OAAO;EAE9C,MAAM,SAAS,CAAC,GAAG,KAAK,QAAQ,SAAS,CAAC,CACvC,QAAQ,CAAC,SAAS,IAAI,WAAW,OAAO,CAAC,CACzC,KAAK,CAAC,KAAK,aAAa;GACvB,MAAM,IAAI,MAAM,OAAO,OAAO,CAAC,MAAM,IAAI,CAAC,MAAM;GAChD,UAAU,IAAI,MAAM,OAAO,OAAO,CAAC,SAAS,IAAI;GAChD,SAAS,OAAO,YAAY;GAC7B,EAAE,CACF,QAAO,UAAS,MAAM,KAAK,SAAS,EAAE;EAKzC,MAAM,UAAU,IAAI,IAClB,OAAO,QAAO,MAAK,EAAE,WAAW,CAAC,EAAE,SAAS,CAAC,KAAI,MAAK,EAAE,KAAK,CAC9D;EACD,MAAM,QAAQ,OACX,QAAO,MAAK,CAAC,EAAE,QAAQ,CACvB,KAAI,MAAK,EAAE,KAAK;EAEnB,MAAM,SAAS,YAAY,QAAO,MAAK,CAAC,QAAQ,IAAI,EAAE,CAAC;AACvD,OAAK,MAAM,QAAQ,MACjB,KAAI,CAAC,OAAO,SAAS,KAAK,CAAE,QAAO,KAAK,KAAK;AAE/C,SAAO;;CAGT,MAAM,WAAW,MAAc,KAAgC;EAC7D,MAAM,MAAM,UAAU,KAAK;EAC3B,MAAM,UAAU,KAAK,QAAQ,IAAI,IAAI;AACrC,MAAI,QAAS,QAAO,QAAQ,YAAY;AACxC,SAAO,KAAK,KAAK,WAAW,MAAM,IAAI;;;AAI1C,SAAS,UAAU,MAAsB;AACvC,QAAO,KAAK,QAAQ,QAAQ,GAAG"}
|