@mapled/cli 0.1.0 → 0.2.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 +91 -11
- package/dist/api.d.ts +4 -0
- package/dist/api.js +22 -5
- package/dist/commands.d.ts +9 -3
- package/dist/commands.js +342 -5
- package/dist/doctor.d.ts +14 -0
- package/dist/doctor.js +48 -0
- package/dist/index.js +28 -2
- package/dist/manifest.d.ts +95 -0
- package/dist/manifest.js +374 -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/doctor.js
CHANGED
|
@@ -2,6 +2,8 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
+
import { compareManifests, manifestSummary, MANIFEST_FILE } from "./manifest.js";
|
|
6
|
+
import { SCHEMA_FILE } from "./pin.js";
|
|
5
7
|
import { splitGenerated } from "./types.js";
|
|
6
8
|
export function ago(iso) {
|
|
7
9
|
const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
|
@@ -414,6 +416,52 @@ export function checkBindings(bindings) {
|
|
|
414
416
|
detail: `${s.healthy ?? 0} healthy • synced ${ago(bindings.manifest.createdAt)} from ${bindings.manifest.clientName}`,
|
|
415
417
|
};
|
|
416
418
|
}
|
|
419
|
+
export function checkSchemaPin(pin, liveHash, changes) {
|
|
420
|
+
const label = "Schema pin";
|
|
421
|
+
if (!pin)
|
|
422
|
+
return { key: "schema", label, status: "skipped", detail: `No ${SCHEMA_FILE} — run \`mapled schema pull\` to track schema changes.` };
|
|
423
|
+
if (!liveHash || !changes)
|
|
424
|
+
return { key: "schema", label, status: "skipped", detail: "Sign in to compare with the schema." };
|
|
425
|
+
if (changes.length === 0)
|
|
426
|
+
return { key: "schema", label, status: "passed", detail: `${SCHEMA_FILE} matches the schema (${pin.hash})` };
|
|
427
|
+
const breaking = changes.filter((c) => c.severity === "breaking").length;
|
|
428
|
+
const n = changes.length;
|
|
429
|
+
const detail = breaking > 0
|
|
430
|
+
? `${n} change${n === 1 ? "" : "s"} since ${SCHEMA_FILE} (${breaking} breaking) — run \`mapled schema diff\`.`
|
|
431
|
+
: `${n} safe change${n === 1 ? "" : "s"} since ${SCHEMA_FILE} — run \`mapled schema diff\`.`;
|
|
432
|
+
return { key: "schema", label, status: "warning", detail };
|
|
433
|
+
}
|
|
434
|
+
export function checkManifestFile(local, remote) {
|
|
435
|
+
const label = "Site manifest";
|
|
436
|
+
if (!local) {
|
|
437
|
+
return { key: "manifest", label, status: "skipped", detail: `No ${MANIFEST_FILE} — run \`mapled scan --write\` to create one from the site's code.` };
|
|
438
|
+
}
|
|
439
|
+
const errors = local.problems.filter((p) => p.level === "error").length;
|
|
440
|
+
if (errors > 0) {
|
|
441
|
+
return { key: "manifest", label, status: "failed", detail: `${MANIFEST_FILE} has ${errors} problem${errors === 1 ? "" : "s"} — run \`mapled manifest validate\`.` };
|
|
442
|
+
}
|
|
443
|
+
if (remote === "unknown")
|
|
444
|
+
return { key: "manifest", label, status: "passed", detail: `${MANIFEST_FILE}: ${manifestSummary(local.manifest)}` };
|
|
445
|
+
if (remote === null)
|
|
446
|
+
return { key: "manifest", label, status: "warning", detail: `${MANIFEST_FILE} isn't pushed yet — run \`mapled bindings push\`.` };
|
|
447
|
+
const diff = compareManifests(local.manifest, remote.manifest);
|
|
448
|
+
if (diff.same) {
|
|
449
|
+
return { key: "manifest", label, status: "passed", detail: `${MANIFEST_FILE} is pushed as manifest v${remote.version} (${manifestSummary(local.manifest)})` };
|
|
450
|
+
}
|
|
451
|
+
const parts = [];
|
|
452
|
+
if (diff.onlyLocal.length > 0)
|
|
453
|
+
parts.push(`${diff.onlyLocal.length} not pushed`);
|
|
454
|
+
if (diff.changed.length > 0)
|
|
455
|
+
parts.push(`${diff.changed.length} changed`);
|
|
456
|
+
if (diff.onlyRemote.length > 0)
|
|
457
|
+
parts.push(`${diff.onlyRemote.length} only in Mapled`);
|
|
458
|
+
return {
|
|
459
|
+
key: "manifest",
|
|
460
|
+
label,
|
|
461
|
+
status: "warning",
|
|
462
|
+
detail: `${MANIFEST_FILE} differs from the pushed manifest (v${remote.version}): ${parts.join(", ")} — run \`mapled bindings push\`.`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
417
465
|
export const REMOTE_CHECKS = [
|
|
418
466
|
["reads", "Site reads content"],
|
|
419
467
|
["webhook", "Publish webhook"],
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,12 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { parseArgs } from "./args.js";
|
|
5
5
|
import { openBrowser } from "./browser.js";
|
|
6
|
-
import { doctor, generate, link, login, logout } from "./commands.js";
|
|
6
|
+
import { bindingsPull, bindingsPush, doctor, generate, link, login, logout, manifestValidate, scan, schemaDiff, schemaPull, } from "./commands.js";
|
|
7
7
|
import { credentialsPath } from "./credentials.js";
|
|
8
8
|
import { CliError } from "./errors.js";
|
|
9
9
|
/** `mapled` — sign in, link a repository to its project, generate types
|
|
10
|
-
for the content,
|
|
10
|
+
for the content, keep the schema pin and the site manifest in step
|
|
11
|
+
with the code, check the integration (§31, waves 1 and 2). */
|
|
11
12
|
const HELP = `mapled — the Mapled CLI
|
|
12
13
|
|
|
13
14
|
Usage
|
|
@@ -15,6 +16,12 @@ Usage
|
|
|
15
16
|
mapled auth logout [--all] Revoke this machine's access to the linked project
|
|
16
17
|
mapled project link [--project <id>] Write mapled.json for this repository
|
|
17
18
|
mapled types generate [--out <file>] Generate TypeScript types for the content
|
|
19
|
+
mapled schema pull Record the schema this site is built against (mapled/schema.json)
|
|
20
|
+
mapled schema diff [--json] [--exit-code] Show what changed in Mapled since the last pull
|
|
21
|
+
mapled scan [--write] [--prune] [--json] Find where the code reads Mapled; --write updates mapled/manifest.json
|
|
22
|
+
mapled manifest validate [--json] Check mapled/manifest.json before pushing it
|
|
23
|
+
mapled bindings push [--dry-run] Push mapled/manifest.json so Mapled grades every binding
|
|
24
|
+
mapled bindings pull [--force] Write the pushed manifest into mapled/manifest.json
|
|
18
25
|
mapled doctor [--json] Check the integration end to end
|
|
19
26
|
|
|
20
27
|
Options
|
|
@@ -80,11 +87,30 @@ async function main(argv) {
|
|
|
80
87
|
case "types generate":
|
|
81
88
|
await generate(ctx, flags);
|
|
82
89
|
return 0;
|
|
90
|
+
case "schema pull":
|
|
91
|
+
await schemaPull(ctx, flags);
|
|
92
|
+
return 0;
|
|
93
|
+
case "schema diff":
|
|
94
|
+
return schemaDiff(ctx, flags);
|
|
95
|
+
case "scan":
|
|
96
|
+
await scan(ctx, flags);
|
|
97
|
+
return 0;
|
|
98
|
+
case "manifest validate":
|
|
99
|
+
return manifestValidate(ctx, flags);
|
|
100
|
+
case "bindings push":
|
|
101
|
+
await bindingsPush(ctx, flags);
|
|
102
|
+
return 0;
|
|
103
|
+
case "bindings pull":
|
|
104
|
+
await bindingsPull(ctx, flags);
|
|
105
|
+
return 0;
|
|
83
106
|
case "doctor":
|
|
84
107
|
return doctor(ctx, flags);
|
|
85
108
|
case "auth":
|
|
86
109
|
case "project":
|
|
87
110
|
case "types":
|
|
111
|
+
case "schema":
|
|
112
|
+
case "manifest":
|
|
113
|
+
case "bindings":
|
|
88
114
|
console.log(HELP);
|
|
89
115
|
return 2;
|
|
90
116
|
default:
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Schema } from "./schema.js";
|
|
2
|
+
/** mapled/manifest.json — the site manifest (§21): the pages of the site
|
|
3
|
+
and where each field is rendered (the bindings). The same shape the
|
|
4
|
+
API's POST /v1/agent/manifest accepts and the AI agent's
|
|
5
|
+
push_site_manifest tool sends; only these fields survive a push.
|
|
6
|
+
Everything in the file is content the site's authors wrote — checked
|
|
7
|
+
against the schema, never interpolated anywhere. */
|
|
8
|
+
export declare const MANIFEST_FILE = "mapled/manifest.json";
|
|
9
|
+
export declare const BINDING_TARGETS: readonly ["text", "rich_text", "image", "image_alt", "link", "number", "date", "boolean", "collection", "route_param", "form_field", "other"];
|
|
10
|
+
export type BindingTarget = (typeof BINDING_TARGETS)[number];
|
|
11
|
+
export type ManifestBinding = {
|
|
12
|
+
key: string;
|
|
13
|
+
page: string;
|
|
14
|
+
component?: string;
|
|
15
|
+
file?: string;
|
|
16
|
+
collection: string;
|
|
17
|
+
field?: string;
|
|
18
|
+
target: BindingTarget;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type ManifestPage = {
|
|
22
|
+
route: string;
|
|
23
|
+
file?: string;
|
|
24
|
+
};
|
|
25
|
+
export type Manifest = {
|
|
26
|
+
framework?: string;
|
|
27
|
+
integrationMode?: string;
|
|
28
|
+
pages?: ManifestPage[];
|
|
29
|
+
bindings: ManifestBinding[];
|
|
30
|
+
notes?: string[];
|
|
31
|
+
};
|
|
32
|
+
export declare const KEY_PATTERN: RegExp;
|
|
33
|
+
export declare const LIMITS: {
|
|
34
|
+
bindings: number;
|
|
35
|
+
pages: number;
|
|
36
|
+
notes: number;
|
|
37
|
+
};
|
|
38
|
+
/** Field types a target renders without a transform — the API's table
|
|
39
|
+
(lib/bindings.ts), which grades each pushed binding the same way. */
|
|
40
|
+
export declare const COMPATIBLE: Partial<Record<BindingTarget, string[]>>;
|
|
41
|
+
/** The natural target of a field type — what `mapled scan` records when
|
|
42
|
+
the site reads the field. */
|
|
43
|
+
export declare function targetFor(fieldType: string | undefined): BindingTarget;
|
|
44
|
+
export declare const FIELD_TYPE_LABEL: Record<string, string>;
|
|
45
|
+
export declare function typeLabel(type: string): string;
|
|
46
|
+
export type Problem = {
|
|
47
|
+
level: "error" | "warning";
|
|
48
|
+
path: string;
|
|
49
|
+
message: string;
|
|
50
|
+
};
|
|
51
|
+
/** Parses the file's text into a manifest with only the known fields,
|
|
52
|
+
reporting every shape problem the API would refuse (as errors) and the
|
|
53
|
+
parts it would silently drop (as warnings). */
|
|
54
|
+
export declare function parseManifest(raw: string, file: string): {
|
|
55
|
+
manifest: Manifest;
|
|
56
|
+
problems: Problem[];
|
|
57
|
+
};
|
|
58
|
+
export type SchemaIndex = Map<string, {
|
|
59
|
+
displayName: string;
|
|
60
|
+
kind: string;
|
|
61
|
+
fields: Map<string, {
|
|
62
|
+
displayName: string;
|
|
63
|
+
type: string;
|
|
64
|
+
}>;
|
|
65
|
+
}>;
|
|
66
|
+
export declare function indexSchema(schema: Schema): SchemaIndex;
|
|
67
|
+
/** What a well-formed manifest still gets wrong: bindings the schema
|
|
68
|
+
can't back (Mapled would grade them outdated or mismatched), files the
|
|
69
|
+
repository doesn't have, pages the list doesn't know. Warnings only —
|
|
70
|
+
the API accepts all of it and the Bindings screen shows the health. */
|
|
71
|
+
export declare function checkManifest(manifest: Manifest, opts: {
|
|
72
|
+
schema: Schema | null;
|
|
73
|
+
fileExists: (rel: string) => Promise<boolean>;
|
|
74
|
+
}): Promise<Problem[]>;
|
|
75
|
+
export declare function fileExistsIn(dir: string): (rel: string) => Promise<boolean>;
|
|
76
|
+
export declare function readManifestFile(dir: string): Promise<{
|
|
77
|
+
file: string;
|
|
78
|
+
raw: string;
|
|
79
|
+
} | null>;
|
|
80
|
+
/** Writes the manifest with its keys in a stable order; bindings keep
|
|
81
|
+
the order they came in. */
|
|
82
|
+
export declare function writeManifestFile(dir: string, manifest: Manifest): Promise<string>;
|
|
83
|
+
export declare function orderManifest(manifest: Manifest): Manifest;
|
|
84
|
+
export declare function orderBinding(b: ManifestBinding): ManifestBinding;
|
|
85
|
+
export declare function plural(n: number, word: string, pluralWord?: string): string;
|
|
86
|
+
export declare function manifestSummary(manifest: Manifest): string;
|
|
87
|
+
/** Bindings that mean the same thing to Mapled (what a push stores). */
|
|
88
|
+
export declare function sameBinding(a: ManifestBinding, b: ManifestBinding): boolean;
|
|
89
|
+
export type ManifestComparison = {
|
|
90
|
+
same: boolean;
|
|
91
|
+
onlyLocal: string[];
|
|
92
|
+
onlyRemote: string[];
|
|
93
|
+
changed: string[];
|
|
94
|
+
};
|
|
95
|
+
export declare function compareManifests(local: Manifest, remote: Manifest): ManifestComparison;
|
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/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;
|