@mapled/cli 0.1.0 → 0.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 +104 -11
- package/dist/api.d.ts +4 -0
- package/dist/api.js +22 -5
- package/dist/commands.d.ts +10 -3
- package/dist/commands.js +375 -5
- package/dist/doctor.d.ts +14 -0
- package/dist/doctor.js +48 -0
- package/dist/index.js +34 -2
- package/dist/manifest.d.ts +95 -0
- package/dist/manifest.js +374 -0
- package/dist/md.d.ts +59 -0
- package/dist/md.js +110 -0
- package/dist/pin.d.ts +41 -0
- package/dist/pin.js +297 -0
- package/dist/scan.d.ts +73 -0
- package/dist/scan.js +1295 -0
- package/dist/schema.d.ts +3 -0
- package/dist/types.js +2 -0
- package/package.json +2 -2
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
/** mapled/manifest.json — the site manifest (§21): the pages of the site
|
|
5
|
+
and where each field is rendered (the bindings). The same shape the
|
|
6
|
+
API's POST /v1/agent/manifest accepts and the AI agent's
|
|
7
|
+
push_site_manifest tool sends; only these fields survive a push.
|
|
8
|
+
Everything in the file is content the site's authors wrote — checked
|
|
9
|
+
against the schema, never interpolated anywhere. */
|
|
10
|
+
export const MANIFEST_FILE = "mapled/manifest.json";
|
|
11
|
+
export const BINDING_TARGETS = [
|
|
12
|
+
"text",
|
|
13
|
+
"rich_text",
|
|
14
|
+
"image",
|
|
15
|
+
"image_alt",
|
|
16
|
+
"link",
|
|
17
|
+
"number",
|
|
18
|
+
"date",
|
|
19
|
+
"boolean",
|
|
20
|
+
"collection",
|
|
21
|
+
"route_param",
|
|
22
|
+
"form_field",
|
|
23
|
+
"other",
|
|
24
|
+
];
|
|
25
|
+
export const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/\[\]-]{0,119}$/;
|
|
26
|
+
export const LIMITS = { bindings: 500, pages: 200, notes: 20 };
|
|
27
|
+
/** Field types a target renders without a transform — the API's table
|
|
28
|
+
(lib/bindings.ts), which grades each pushed binding the same way. */
|
|
29
|
+
export const COMPATIBLE = {
|
|
30
|
+
text: ["short_text", "long_text", "slug", "number", "date", "datetime", "enum", "url", "email", "color"],
|
|
31
|
+
rich_text: ["rich_text", "long_text"],
|
|
32
|
+
image: ["image"],
|
|
33
|
+
image_alt: ["short_text", "long_text"],
|
|
34
|
+
link: ["short_text", "slug", "url", "email", "file"],
|
|
35
|
+
number: ["number"],
|
|
36
|
+
date: ["date", "datetime"],
|
|
37
|
+
boolean: ["boolean"],
|
|
38
|
+
route_param: ["slug", "short_text"],
|
|
39
|
+
};
|
|
40
|
+
/** The natural target of a field type — what `mapled scan` records when
|
|
41
|
+
the site reads the field. */
|
|
42
|
+
export function targetFor(fieldType) {
|
|
43
|
+
switch (fieldType) {
|
|
44
|
+
case "rich_text":
|
|
45
|
+
return "rich_text";
|
|
46
|
+
case "image":
|
|
47
|
+
return "image";
|
|
48
|
+
case "url":
|
|
49
|
+
case "email":
|
|
50
|
+
case "file":
|
|
51
|
+
return "link";
|
|
52
|
+
case "number":
|
|
53
|
+
return "number";
|
|
54
|
+
case "date":
|
|
55
|
+
case "datetime":
|
|
56
|
+
return "date";
|
|
57
|
+
case "boolean":
|
|
58
|
+
return "boolean";
|
|
59
|
+
case "relation":
|
|
60
|
+
case "group":
|
|
61
|
+
case "json":
|
|
62
|
+
case "location":
|
|
63
|
+
return "other";
|
|
64
|
+
default:
|
|
65
|
+
return "text";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export const FIELD_TYPE_LABEL = {
|
|
69
|
+
short_text: "short text",
|
|
70
|
+
long_text: "long text",
|
|
71
|
+
rich_text: "rich text",
|
|
72
|
+
slug: "slug",
|
|
73
|
+
image: "image",
|
|
74
|
+
file: "file",
|
|
75
|
+
number: "number",
|
|
76
|
+
boolean: "boolean",
|
|
77
|
+
date: "date",
|
|
78
|
+
datetime: "date and time",
|
|
79
|
+
enum: "choice",
|
|
80
|
+
url: "URL",
|
|
81
|
+
email: "email",
|
|
82
|
+
color: "color",
|
|
83
|
+
relation: "relation",
|
|
84
|
+
group: "group",
|
|
85
|
+
json: "JSON",
|
|
86
|
+
location: "location",
|
|
87
|
+
};
|
|
88
|
+
export function typeLabel(type) {
|
|
89
|
+
return FIELD_TYPE_LABEL[type] ?? type;
|
|
90
|
+
}
|
|
91
|
+
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
92
|
+
const isText = (v, max, min = 1) => typeof v === "string" && v.length >= min && v.length <= max;
|
|
93
|
+
const TOP_KEYS = new Set(["framework", "integrationMode", "pages", "bindings", "notes"]);
|
|
94
|
+
const BINDING_KEYS = new Set(["key", "page", "component", "file", "collection", "field", "target", "required"]);
|
|
95
|
+
/** Parses the file's text into a manifest with only the known fields,
|
|
96
|
+
reporting every shape problem the API would refuse (as errors) and the
|
|
97
|
+
parts it would silently drop (as warnings). */
|
|
98
|
+
export function parseManifest(raw, file) {
|
|
99
|
+
let data;
|
|
100
|
+
try {
|
|
101
|
+
data = JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new CliError(`${file} isn't valid JSON.`);
|
|
105
|
+
}
|
|
106
|
+
const problems = [];
|
|
107
|
+
const error = (p, message) => problems.push({ level: "error", path: p, message });
|
|
108
|
+
const warn = (p, message) => problems.push({ level: "warning", path: p, message });
|
|
109
|
+
const manifest = { bindings: [] };
|
|
110
|
+
if (!isObject(data)) {
|
|
111
|
+
error("", `${file} must be a JSON object with a "bindings" list.`);
|
|
112
|
+
return { manifest, problems };
|
|
113
|
+
}
|
|
114
|
+
for (const key of Object.keys(data)) {
|
|
115
|
+
if (!TOP_KEYS.has(key))
|
|
116
|
+
warn(key, `"${key}" isn't part of the manifest and is dropped on push.`);
|
|
117
|
+
}
|
|
118
|
+
if (data.framework !== undefined) {
|
|
119
|
+
if (isText(data.framework, 40))
|
|
120
|
+
manifest.framework = data.framework;
|
|
121
|
+
else
|
|
122
|
+
error("framework", `"framework" must be a short name (up to 40 characters).`);
|
|
123
|
+
}
|
|
124
|
+
if (data.integrationMode !== undefined) {
|
|
125
|
+
if (isText(data.integrationMode, 40))
|
|
126
|
+
manifest.integrationMode = data.integrationMode;
|
|
127
|
+
else
|
|
128
|
+
error("integrationMode", `"integrationMode" must be a short name (up to 40 characters).`);
|
|
129
|
+
}
|
|
130
|
+
if (data.pages !== undefined) {
|
|
131
|
+
if (!Array.isArray(data.pages))
|
|
132
|
+
error("pages", `"pages" must be a list of { route, file }.`);
|
|
133
|
+
else {
|
|
134
|
+
if (data.pages.length > LIMITS.pages)
|
|
135
|
+
error("pages", `"pages" has ${data.pages.length} entries — the limit is ${LIMITS.pages}.`);
|
|
136
|
+
manifest.pages = [];
|
|
137
|
+
data.pages.forEach((p, i) => {
|
|
138
|
+
const at = `pages[${i}]`;
|
|
139
|
+
if (!isObject(p))
|
|
140
|
+
return error(at, `${at} must be an object with a "route".`);
|
|
141
|
+
if (!isText(p.route, 200))
|
|
142
|
+
return error(`${at}.route`, `${at}.route must be the page route, e.g. "/" or "/blog/[slug]".`);
|
|
143
|
+
const page = { route: p.route };
|
|
144
|
+
if (p.file !== undefined) {
|
|
145
|
+
if (isText(p.file, 300, 0))
|
|
146
|
+
page.file = p.file;
|
|
147
|
+
else
|
|
148
|
+
return error(`${at}.file`, `${at}.file must be a path (up to 300 characters).`);
|
|
149
|
+
}
|
|
150
|
+
manifest.pages.push(page);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!Array.isArray(data.bindings)) {
|
|
155
|
+
error("bindings", `"bindings" must be a list of bindings — { key, page, collection, field, target } each.`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
if (data.bindings.length > LIMITS.bindings) {
|
|
159
|
+
error("bindings", `"bindings" has ${data.bindings.length} entries — the limit is ${LIMITS.bindings}.`);
|
|
160
|
+
}
|
|
161
|
+
const seen = new Map();
|
|
162
|
+
data.bindings.forEach((b, i) => {
|
|
163
|
+
const at = `bindings[${i}]`;
|
|
164
|
+
if (!isObject(b))
|
|
165
|
+
return error(at, `${at} must be an object.`);
|
|
166
|
+
for (const key of Object.keys(b)) {
|
|
167
|
+
if (!BINDING_KEYS.has(key))
|
|
168
|
+
warn(`${at}.${key}`, `${at}.${key} isn't part of a binding and is dropped on push.`);
|
|
169
|
+
}
|
|
170
|
+
let ok = true;
|
|
171
|
+
if (typeof b.key !== "string" || !KEY_PATTERN.test(b.key)) {
|
|
172
|
+
error(`${at}.key`, `${at}.key must be 1–120 characters — letters, digits, . _ : / [ ] and -, starting with a letter or digit.`);
|
|
173
|
+
ok = false;
|
|
174
|
+
}
|
|
175
|
+
else if (seen.has(b.key)) {
|
|
176
|
+
error(`${at}.key`, `${at}.key "${b.key}" is already used by bindings[${seen.get(b.key)}].`);
|
|
177
|
+
ok = false;
|
|
178
|
+
}
|
|
179
|
+
else
|
|
180
|
+
seen.set(b.key, i);
|
|
181
|
+
if (!isText(b.page, 200)) {
|
|
182
|
+
error(`${at}.page`, `${at}.page must name the page route, e.g. "/" or "/blog/[slug]".`);
|
|
183
|
+
ok = false;
|
|
184
|
+
}
|
|
185
|
+
if (!isText(b.collection, 120)) {
|
|
186
|
+
error(`${at}.collection`, `${at}.collection must be a collection key.`);
|
|
187
|
+
ok = false;
|
|
188
|
+
}
|
|
189
|
+
if (b.field !== undefined && !isText(b.field, 120)) {
|
|
190
|
+
error(`${at}.field`, `${at}.field must be a field key (up to 120 characters).`);
|
|
191
|
+
ok = false;
|
|
192
|
+
}
|
|
193
|
+
if (typeof b.target !== "string" || !BINDING_TARGETS.includes(b.target)) {
|
|
194
|
+
error(`${at}.target`, `${at}.target ${typeof b.target === "string" ? `"${b.target}" ` : ""}isn't one of ${BINDING_TARGETS.join(", ")}.`);
|
|
195
|
+
ok = false;
|
|
196
|
+
}
|
|
197
|
+
if (b.component !== undefined && !isText(b.component, 120, 0)) {
|
|
198
|
+
error(`${at}.component`, `${at}.component must be a name (up to 120 characters).`);
|
|
199
|
+
ok = false;
|
|
200
|
+
}
|
|
201
|
+
if (b.file !== undefined && !isText(b.file, 300, 0)) {
|
|
202
|
+
error(`${at}.file`, `${at}.file must be a path (up to 300 characters).`);
|
|
203
|
+
ok = false;
|
|
204
|
+
}
|
|
205
|
+
if (b.required !== undefined && typeof b.required !== "boolean") {
|
|
206
|
+
error(`${at}.required`, `${at}.required must be true or false.`);
|
|
207
|
+
ok = false;
|
|
208
|
+
}
|
|
209
|
+
if (!ok)
|
|
210
|
+
return;
|
|
211
|
+
const binding = {
|
|
212
|
+
key: b.key,
|
|
213
|
+
page: b.page,
|
|
214
|
+
collection: b.collection,
|
|
215
|
+
target: b.target,
|
|
216
|
+
};
|
|
217
|
+
if (typeof b.component === "string")
|
|
218
|
+
binding.component = b.component;
|
|
219
|
+
if (typeof b.file === "string")
|
|
220
|
+
binding.file = b.file;
|
|
221
|
+
if (typeof b.field === "string")
|
|
222
|
+
binding.field = b.field;
|
|
223
|
+
if (typeof b.required === "boolean")
|
|
224
|
+
binding.required = b.required;
|
|
225
|
+
manifest.bindings.push(binding);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (data.notes !== undefined) {
|
|
229
|
+
if (!Array.isArray(data.notes) || data.notes.some((n) => !isText(n, 300, 0))) {
|
|
230
|
+
error("notes", `"notes" must be a list of short texts (up to 300 characters each).`);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
if (data.notes.length > LIMITS.notes)
|
|
234
|
+
error("notes", `"notes" has ${data.notes.length} entries — the limit is ${LIMITS.notes}.`);
|
|
235
|
+
manifest.notes = data.notes;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { manifest, problems };
|
|
239
|
+
}
|
|
240
|
+
export function indexSchema(schema) {
|
|
241
|
+
const index = new Map();
|
|
242
|
+
for (const c of schema.collections) {
|
|
243
|
+
const fields = new Map();
|
|
244
|
+
for (const f of c.fields)
|
|
245
|
+
fields.set(f.key, { displayName: f.displayName, type: f.type });
|
|
246
|
+
index.set(c.key, { displayName: c.displayName, kind: c.kind, fields });
|
|
247
|
+
}
|
|
248
|
+
return index;
|
|
249
|
+
}
|
|
250
|
+
function article(word) {
|
|
251
|
+
return /^[aeiou]/i.test(word) ? "an" : "a";
|
|
252
|
+
}
|
|
253
|
+
/** What a well-formed manifest still gets wrong: bindings the schema
|
|
254
|
+
can't back (Mapled would grade them outdated or mismatched), files the
|
|
255
|
+
repository doesn't have, pages the list doesn't know. Warnings only —
|
|
256
|
+
the API accepts all of it and the Bindings screen shows the health. */
|
|
257
|
+
export async function checkManifest(manifest, opts) {
|
|
258
|
+
const problems = [];
|
|
259
|
+
const warn = (p, message) => problems.push({ level: "warning", path: p, message });
|
|
260
|
+
const index = opts.schema ? indexSchema(opts.schema) : null;
|
|
261
|
+
const routes = new Set((manifest.pages ?? []).map((p) => p.route));
|
|
262
|
+
for (const [i, p] of (manifest.pages ?? []).entries()) {
|
|
263
|
+
if (p.file && !(await opts.fileExists(p.file))) {
|
|
264
|
+
warn(`pages[${i}].file`, `pages[${i}] (${p.route}): ${p.file} doesn't exist in the repository.`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const [i, b] of manifest.bindings.entries()) {
|
|
268
|
+
const at = `bindings[${i}]`;
|
|
269
|
+
const who = `${at} (${b.key})`;
|
|
270
|
+
if (index) {
|
|
271
|
+
const collection = index.get(b.collection);
|
|
272
|
+
if (!collection)
|
|
273
|
+
warn(`${at}.collection`, `${who}: no collection "${b.collection}" in the schema — Mapled marks it outdated.`);
|
|
274
|
+
else if (b.field) {
|
|
275
|
+
const field = collection.fields.get(b.field);
|
|
276
|
+
if (!field) {
|
|
277
|
+
warn(`${at}.field`, `${who}: no field "${b.field}" in "${collection.displayName}" — Mapled marks it outdated.`);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
const allowed = COMPATIBLE[b.target];
|
|
281
|
+
if (allowed && !allowed.includes(field.type)) {
|
|
282
|
+
const label = typeLabel(field.type);
|
|
283
|
+
warn(`${at}.target`, `${who}: "${field.displayName}" is ${article(label)} ${label} field, but the binding renders it as ${b.target.replace("_", " ")}.`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (b.file && !(await opts.fileExists(b.file)))
|
|
289
|
+
warn(`${at}.file`, `${who}: ${b.file} doesn't exist in the repository.`);
|
|
290
|
+
if (routes.size > 0 && !routes.has(b.page))
|
|
291
|
+
warn(`${at}.page`, `${who}: page "${b.page}" isn't listed in "pages".`);
|
|
292
|
+
if (b.target === "route_param" && !/\[[^\]]+\]/.test(b.page)) {
|
|
293
|
+
warn(`${at}.target`, `${who}: route_param on "${b.page}", which has no dynamic segment like [slug].`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return problems;
|
|
297
|
+
}
|
|
298
|
+
export function fileExistsIn(dir) {
|
|
299
|
+
return async (rel) => {
|
|
300
|
+
const target = path.resolve(dir, rel);
|
|
301
|
+
if (path.relative(dir, target).startsWith(".."))
|
|
302
|
+
return false;
|
|
303
|
+
try {
|
|
304
|
+
await access(target);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
export async function readManifestFile(dir) {
|
|
313
|
+
const file = path.join(dir, MANIFEST_FILE);
|
|
314
|
+
try {
|
|
315
|
+
return { file, raw: await readFile(file, "utf8") };
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Writes the manifest with its keys in a stable order; bindings keep
|
|
322
|
+
the order they came in. */
|
|
323
|
+
export async function writeManifestFile(dir, manifest) {
|
|
324
|
+
const file = path.join(dir, MANIFEST_FILE);
|
|
325
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
326
|
+
await writeFile(file, JSON.stringify(orderManifest(manifest), null, 2) + "\n");
|
|
327
|
+
return file;
|
|
328
|
+
}
|
|
329
|
+
export function orderManifest(manifest) {
|
|
330
|
+
return {
|
|
331
|
+
...(manifest.framework ? { framework: manifest.framework } : {}),
|
|
332
|
+
...(manifest.integrationMode ? { integrationMode: manifest.integrationMode } : {}),
|
|
333
|
+
...(manifest.pages ? { pages: manifest.pages.map((p) => ({ route: p.route, ...(p.file !== undefined ? { file: p.file } : {}) })) } : {}),
|
|
334
|
+
bindings: manifest.bindings.map(orderBinding),
|
|
335
|
+
...(manifest.notes && manifest.notes.length > 0 ? { notes: [...manifest.notes] } : {}),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
export function orderBinding(b) {
|
|
339
|
+
const out = { key: b.key, page: b.page, collection: b.collection, target: b.target };
|
|
340
|
+
if (b.field !== undefined)
|
|
341
|
+
out.field = b.field;
|
|
342
|
+
if (b.component !== undefined)
|
|
343
|
+
out.component = b.component;
|
|
344
|
+
if (b.file !== undefined)
|
|
345
|
+
out.file = b.file;
|
|
346
|
+
if (b.required !== undefined)
|
|
347
|
+
out.required = b.required;
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
export function plural(n, word, pluralWord = `${word}s`) {
|
|
351
|
+
return `${n} ${n === 1 ? word : pluralWord}`;
|
|
352
|
+
}
|
|
353
|
+
export function manifestSummary(manifest) {
|
|
354
|
+
const pages = new Set([...(manifest.pages ?? []).map((p) => p.route), ...manifest.bindings.map((b) => b.page)]);
|
|
355
|
+
return `${plural(manifest.bindings.length, "binding")} on ${plural(pages.size, "page")}`;
|
|
356
|
+
}
|
|
357
|
+
/** Bindings that mean the same thing to Mapled (what a push stores). */
|
|
358
|
+
export function sameBinding(a, b) {
|
|
359
|
+
return (a.page === b.page &&
|
|
360
|
+
a.collection === b.collection &&
|
|
361
|
+
(a.field ?? null) === (b.field ?? null) &&
|
|
362
|
+
a.target === b.target &&
|
|
363
|
+
(a.required ?? true) === (b.required ?? true) &&
|
|
364
|
+
(a.component ?? null) === (b.component ?? null) &&
|
|
365
|
+
(a.file ?? null) === (b.file ?? null));
|
|
366
|
+
}
|
|
367
|
+
export function compareManifests(local, remote) {
|
|
368
|
+
const remoteByKey = new Map(remote.bindings.map((b) => [b.key, b]));
|
|
369
|
+
const localByKey = new Map(local.bindings.map((b) => [b.key, b]));
|
|
370
|
+
const onlyLocal = local.bindings.filter((b) => !remoteByKey.has(b.key)).map((b) => b.key);
|
|
371
|
+
const onlyRemote = remote.bindings.filter((b) => !localByKey.has(b.key)).map((b) => b.key);
|
|
372
|
+
const changed = local.bindings.filter((b) => remoteByKey.has(b.key) && !sameBinding(b, remoteByKey.get(b.key))).map((b) => b.key);
|
|
373
|
+
return { same: onlyLocal.length === 0 && onlyRemote.length === 0 && changed.length === 0, onlyLocal, onlyRemote, changed };
|
|
374
|
+
}
|
package/dist/md.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Check } from "./doctor.js";
|
|
2
|
+
/** MAPLED.md (§21.3): Mapled renders the guide for the next agent,
|
|
3
|
+
the repository keeps it. `mapled md pull` writes what
|
|
4
|
+
GET /v1/agent/mapled-md answers and keeps everything below the notes
|
|
5
|
+
marker — that part is the repository's own; `doctor` compares the
|
|
6
|
+
rendered part of the local file with a fresh render. The first line
|
|
7
|
+
is a stamp (`<!-- mapled: project=… schema=… manifest=… generated=… -->`)
|
|
8
|
+
and the only line allowed to differ between a fresh file and an
|
|
9
|
+
up-to-date one. */
|
|
10
|
+
export declare const MD_FILE = "MAPLED.md";
|
|
11
|
+
export declare const NOTES_MARKER = "<!-- mapled:notes -->";
|
|
12
|
+
/** GET /v1/agent/mapled-md */
|
|
13
|
+
export type RenderedMd = {
|
|
14
|
+
file: string;
|
|
15
|
+
notesMarker: string;
|
|
16
|
+
markdown: string;
|
|
17
|
+
schemaHash: string;
|
|
18
|
+
manifestVersion: number | null;
|
|
19
|
+
counts: {
|
|
20
|
+
collections: number;
|
|
21
|
+
singles: number;
|
|
22
|
+
forms: number;
|
|
23
|
+
bindings: number;
|
|
24
|
+
pages: number;
|
|
25
|
+
};
|
|
26
|
+
generatedAt: string;
|
|
27
|
+
};
|
|
28
|
+
export type MdStamp = {
|
|
29
|
+
project: string | null;
|
|
30
|
+
schema: string | null;
|
|
31
|
+
manifest: string | null;
|
|
32
|
+
};
|
|
33
|
+
export type MdParts = {
|
|
34
|
+
stamp: MdStamp;
|
|
35
|
+
/** the stamp line as written */
|
|
36
|
+
stampLine: string;
|
|
37
|
+
/** everything between the stamp and the notes marker */
|
|
38
|
+
body: string;
|
|
39
|
+
/** everything after the marker line; null when the file has no marker */
|
|
40
|
+
notes: string | null;
|
|
41
|
+
};
|
|
42
|
+
/** Splits a MAPLED.md written by Mapled; null for any other file. */
|
|
43
|
+
export declare function splitMd(text: string): MdParts | null;
|
|
44
|
+
export type MergedMd = {
|
|
45
|
+
kind: "unchanged";
|
|
46
|
+
} | {
|
|
47
|
+
kind: "created" | "updated" | "replaced";
|
|
48
|
+
text: string;
|
|
49
|
+
};
|
|
50
|
+
/** The file to write: the fresh render, with the repository's notes kept.
|
|
51
|
+
A file Mapled did not write is left alone unless forced — then its
|
|
52
|
+
whole content moves below the notes line, so nothing is lost. */
|
|
53
|
+
export declare function mergeMd(fresh: string, existing: string | null, opts?: {
|
|
54
|
+
force?: boolean;
|
|
55
|
+
}): MergedMd;
|
|
56
|
+
/** `doctor`'s line: is the repository's guide what Mapled would render now?
|
|
57
|
+
`fresh` is null without a sign-in and "unavailable" when Mapled
|
|
58
|
+
answered the other calls but not this one (an API without the route). */
|
|
59
|
+
export declare function checkMapledMd(local: string | null, fresh: RenderedMd | null | "unavailable"): Check;
|
package/dist/md.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
/** MAPLED.md (§21.3): Mapled renders the guide for the next agent,
|
|
3
|
+
the repository keeps it. `mapled md pull` writes what
|
|
4
|
+
GET /v1/agent/mapled-md answers and keeps everything below the notes
|
|
5
|
+
marker — that part is the repository's own; `doctor` compares the
|
|
6
|
+
rendered part of the local file with a fresh render. The first line
|
|
7
|
+
is a stamp (`<!-- mapled: project=… schema=… manifest=… generated=… -->`)
|
|
8
|
+
and the only line allowed to differ between a fresh file and an
|
|
9
|
+
up-to-date one. */
|
|
10
|
+
export const MD_FILE = "MAPLED.md";
|
|
11
|
+
export const NOTES_MARKER = "<!-- mapled:notes -->";
|
|
12
|
+
/** Splits a MAPLED.md written by Mapled; null for any other file. */
|
|
13
|
+
export function splitMd(text) {
|
|
14
|
+
const lines = text.split("\n");
|
|
15
|
+
const first = lines[0] ?? "";
|
|
16
|
+
const m = /^<!-- mapled: (.*) -->\s*$/.exec(first);
|
|
17
|
+
if (!m)
|
|
18
|
+
return null;
|
|
19
|
+
const stamp = { project: null, schema: null, manifest: null };
|
|
20
|
+
for (const pair of m[1].split(/\s+/)) {
|
|
21
|
+
const eq = pair.indexOf("=");
|
|
22
|
+
if (eq === -1)
|
|
23
|
+
continue;
|
|
24
|
+
const key = pair.slice(0, eq);
|
|
25
|
+
const value = pair.slice(eq + 1);
|
|
26
|
+
if (key === "project" || key === "schema" || key === "manifest")
|
|
27
|
+
stamp[key] = value;
|
|
28
|
+
}
|
|
29
|
+
const marker = lines.indexOf(NOTES_MARKER, 1);
|
|
30
|
+
const body = lines.slice(1, marker === -1 ? lines.length : marker).join("\n");
|
|
31
|
+
const notes = marker === -1 ? null : lines.slice(marker + 1).join("\n");
|
|
32
|
+
return { stamp, stampLine: first, body, notes };
|
|
33
|
+
}
|
|
34
|
+
/** The same render: the body and what the stamp says about it, never
|
|
35
|
+
the time it was rendered at. */
|
|
36
|
+
function sameRender(a, b) {
|
|
37
|
+
return a.body === b.body && a.stamp.schema === b.stamp.schema && a.stamp.manifest === b.stamp.manifest;
|
|
38
|
+
}
|
|
39
|
+
/** The file to write: the fresh render, with the repository's notes kept.
|
|
40
|
+
A file Mapled did not write is left alone unless forced — then its
|
|
41
|
+
whole content moves below the notes line, so nothing is lost. */
|
|
42
|
+
export function mergeMd(fresh, existing, opts = {}) {
|
|
43
|
+
const next = splitMd(fresh);
|
|
44
|
+
if (!next || next.notes === null)
|
|
45
|
+
throw new CliError("Mapled answered a guide without its stamp or notes marker — update @mapled/cli and try again.");
|
|
46
|
+
if (existing === null)
|
|
47
|
+
return { kind: "created", text: fresh };
|
|
48
|
+
const current = splitMd(existing);
|
|
49
|
+
if (!current) {
|
|
50
|
+
if (!opts.force) {
|
|
51
|
+
throw new CliError(`${MD_FILE} here wasn't written by Mapled. Pass --force to replace it — the current content moves below the notes line, nothing is lost.`);
|
|
52
|
+
}
|
|
53
|
+
const kept = existing.replace(/\s+$/, "");
|
|
54
|
+
return { kind: "replaced", text: `${next.stampLine}\n${next.body}\n${NOTES_MARKER}\n## Notes\n\n${kept}\n` };
|
|
55
|
+
}
|
|
56
|
+
if (sameRender(current, next) && current.notes !== null)
|
|
57
|
+
return { kind: "unchanged" };
|
|
58
|
+
const notes = current.notes === null || current.notes.trim() === "" ? next.notes : current.notes;
|
|
59
|
+
return { kind: "updated", text: `${next.stampLine}\n${next.body}\n${NOTES_MARKER}\n${notes}` };
|
|
60
|
+
}
|
|
61
|
+
/** `doctor`'s line: is the repository's guide what Mapled would render now?
|
|
62
|
+
`fresh` is null without a sign-in and "unavailable" when Mapled
|
|
63
|
+
answered the other calls but not this one (an API without the route). */
|
|
64
|
+
export function checkMapledMd(local, fresh) {
|
|
65
|
+
const label = "MAPLED.md";
|
|
66
|
+
if (local === null) {
|
|
67
|
+
return { key: "mapled_md", label, status: "skipped", detail: `No ${MD_FILE} — run \`mapled md pull\` so the next agent starts from the current integration.` };
|
|
68
|
+
}
|
|
69
|
+
if (fresh === null)
|
|
70
|
+
return { key: "mapled_md", label, status: "skipped", detail: "Sign in to compare with Mapled." };
|
|
71
|
+
if (fresh === "unavailable") {
|
|
72
|
+
return { key: "mapled_md", label, status: "skipped", detail: "Mapled didn't answer with a guide — update @mapled/cli, or try again later." };
|
|
73
|
+
}
|
|
74
|
+
const current = splitMd(local);
|
|
75
|
+
if (!current) {
|
|
76
|
+
return {
|
|
77
|
+
key: "mapled_md",
|
|
78
|
+
label,
|
|
79
|
+
status: "warning",
|
|
80
|
+
detail: `${MD_FILE} wasn't written by Mapled — run \`mapled md pull --force\` to replace it (the current content moves below the notes line).`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const next = splitMd(fresh.markdown);
|
|
84
|
+
const manifest = fresh.manifestVersion === null ? "none" : `v${fresh.manifestVersion}`;
|
|
85
|
+
if (next && sameRender(current, next) && current.notes !== null) {
|
|
86
|
+
return {
|
|
87
|
+
key: "mapled_md",
|
|
88
|
+
label,
|
|
89
|
+
status: "passed",
|
|
90
|
+
detail: `${MD_FILE} matches the schema (${fresh.schemaHash})${fresh.manifestVersion === null ? "" : ` and manifest ${manifest}`}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (current.stamp.schema && current.stamp.schema !== fresh.schemaHash) {
|
|
94
|
+
return {
|
|
95
|
+
key: "mapled_md",
|
|
96
|
+
label,
|
|
97
|
+
status: "warning",
|
|
98
|
+
detail: `${MD_FILE} describes schema ${current.stamp.schema}; the schema is ${fresh.schemaHash} — run \`mapled md pull\`.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (current.stamp.manifest && current.stamp.manifest !== manifest) {
|
|
102
|
+
return {
|
|
103
|
+
key: "mapled_md",
|
|
104
|
+
label,
|
|
105
|
+
status: "warning",
|
|
106
|
+
detail: `${MD_FILE} describes manifest ${current.stamp.manifest}; ${manifest === "none" ? "none is pushed now" : `${manifest} is pushed`} — run \`mapled md pull\`.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { key: "mapled_md", label, status: "warning", detail: `${MD_FILE} is out of date — run \`mapled md pull\`.` };
|
|
110
|
+
}
|
package/dist/pin.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Schema, type SchemaCollection } from "./schema.js";
|
|
2
|
+
/** mapled/schema.json — the repository's pin of the schema it was last
|
|
3
|
+
synced with (§21.5). `mapled schema diff` compares it with the live
|
|
4
|
+
schema and says which changes a site that READS the content must
|
|
5
|
+
care about; `schema pull` and `types generate` move the pin. The
|
|
6
|
+
file is deterministic: no timestamps, ids or counters. */
|
|
7
|
+
export declare const SCHEMA_FILE = "mapled/schema.json";
|
|
8
|
+
export type SchemaPin = {
|
|
9
|
+
project: string;
|
|
10
|
+
hash: string;
|
|
11
|
+
collections: SchemaCollection[];
|
|
12
|
+
};
|
|
13
|
+
/** The schema reduced to what the pin keeps — the live answer of
|
|
14
|
+
GET /v1/agent/schema without ids, positions and counters. */
|
|
15
|
+
export declare function pinSchema(schema: Schema, project: string): SchemaPin;
|
|
16
|
+
export declare function parsePin(raw: string, file: string): SchemaPin;
|
|
17
|
+
export declare function readPin(dir: string): Promise<{
|
|
18
|
+
file: string;
|
|
19
|
+
pin: SchemaPin;
|
|
20
|
+
} | null>;
|
|
21
|
+
export declare function writePin(dir: string, pin: SchemaPin): Promise<string>;
|
|
22
|
+
export type Severity = "breaking" | "safe";
|
|
23
|
+
export type ChangeOp = "added" | "removed" | "changed";
|
|
24
|
+
export type SchemaChange = {
|
|
25
|
+
scope: "collection" | "field" | "subfield";
|
|
26
|
+
collection: string;
|
|
27
|
+
collectionName: string;
|
|
28
|
+
/** The field key; "group.sub" for a sub-field. */
|
|
29
|
+
field?: string;
|
|
30
|
+
op: ChangeOp;
|
|
31
|
+
detail: string;
|
|
32
|
+
severity: Severity;
|
|
33
|
+
};
|
|
34
|
+
/** What changed between two schemas, judged for a site that reads the
|
|
35
|
+
content: anything that can make a read fail or a value disappear is
|
|
36
|
+
breaking; additions and renames of labels are safe. */
|
|
37
|
+
export declare function diffSchema(before: SchemaCollection[], after: SchemaCollection[]): SchemaChange[];
|
|
38
|
+
/** The diff as terminal lines: one heading per collection, one line per
|
|
39
|
+
change, the severity in the last column. */
|
|
40
|
+
export declare function formatChanges(changes: SchemaChange[], paint?: (severity: Severity, text: string) => string): string[];
|
|
41
|
+
export declare function summarizeChanges(changes: SchemaChange[]): string;
|