@mapled/cli 0.16.0 → 0.17.1
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 +25 -2
- package/dist/args.js +1 -1
- package/dist/callbackPage.d.ts +50 -0
- package/dist/callbackPage.js +141 -0
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +104 -16
- package/dist/doctor.d.ts +2 -0
- package/dist/envPush.d.ts +44 -0
- package/dist/envPush.js +285 -0
- package/dist/index.js +10 -1
- package/dist/logo.d.ts +4 -0
- package/dist/logo.js +4 -0
- package/dist/manifest.d.ts +34 -7
- package/dist/manifest.js +86 -18
- package/dist/media.d.ts +37 -0
- package/dist/media.js +133 -0
- package/dist/oauth.d.ts +14 -2
- package/dist/oauth.js +34 -11
- package/dist/pin.js +63 -43
- package/dist/scanHtml.d.ts +11 -4
- package/dist/scanHtml.js +343 -35
- package/dist/schema.d.ts +17 -1
- package/dist/schema.js +20 -0
- package/dist/types.js +25 -5
- package/dist/vanillaDigests.js +1 -0
- package/package.json +1 -1
package/dist/media.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Session } from "./api.js";
|
|
2
|
+
import type { Fetch } from "./oauth.js";
|
|
3
|
+
export type Asset = {
|
|
4
|
+
id: string;
|
|
5
|
+
url: string;
|
|
6
|
+
fileName: string;
|
|
7
|
+
mime: string;
|
|
8
|
+
sizeBytes: number;
|
|
9
|
+
};
|
|
10
|
+
export type Uploaded = {
|
|
11
|
+
source: string;
|
|
12
|
+
ok: true;
|
|
13
|
+
asset: Asset;
|
|
14
|
+
deduplicated: boolean;
|
|
15
|
+
} | {
|
|
16
|
+
source: string;
|
|
17
|
+
ok: false;
|
|
18
|
+
error: string;
|
|
19
|
+
};
|
|
20
|
+
/** The file a source names in the repository: its path from the root —
|
|
21
|
+
or, written from the site's root (`/images/a.png`), under `public/`
|
|
22
|
+
or `static/` as the frameworks serve it. Never a file outside the
|
|
23
|
+
repository, links included. */
|
|
24
|
+
export declare function locate(root: string, source: string): Promise<string | null>;
|
|
25
|
+
/** One source into Media: a file of the repository at `root`, or a
|
|
26
|
+
public address. */
|
|
27
|
+
export declare function uploadSource(session: Session, f: Fetch, root: string, source: string, runId?: string): Promise<Uploaded>;
|
|
28
|
+
/** Every source, a few at a time, in the order given. */
|
|
29
|
+
export declare function uploadAll(session: Session, f: Fetch, root: string, sources: string[], runId?: string): Promise<Uploaded[]>;
|
|
30
|
+
/** The sources of a setup run's plan that aren't in Mapled yet. */
|
|
31
|
+
export declare function pendingOf(session: Session, runId: string): Promise<{
|
|
32
|
+
status: string;
|
|
33
|
+
pending: string[];
|
|
34
|
+
total: number;
|
|
35
|
+
}>;
|
|
36
|
+
/** One line per upload: what it became, or why it didn't. */
|
|
37
|
+
export declare function uploadLine(r: Uploaded): string;
|
package/dist/media.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ApiError } from "./api.js";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
/** `mapled media upload` (F3): the site's images and files into the
|
|
7
|
+
project's Media. A file of the repository goes sign → PUT → complete
|
|
8
|
+
(`/v1/agent/media/*`); a public address Mapled downloads itself
|
|
9
|
+
(`import`). With a setup run, each upload is a media source of its
|
|
10
|
+
approved plan, and the apply puts the assets into the records. */
|
|
11
|
+
/** The types Media takes, by extension. */
|
|
12
|
+
const TYPES = {
|
|
13
|
+
".png": "image/png",
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".jpeg": "image/jpeg",
|
|
16
|
+
".webp": "image/webp",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".svg": "image/svg+xml",
|
|
19
|
+
".pdf": "application/pdf",
|
|
20
|
+
".glb": "model/gltf-binary",
|
|
21
|
+
".gltf": "model/gltf+json",
|
|
22
|
+
};
|
|
23
|
+
/** What Media's upload says: "Up to 10 MB per image"; a PDF or a 3D model up to 25 MB. */
|
|
24
|
+
const IMAGE_LIMIT = 10 * 1024 * 1024;
|
|
25
|
+
const FILE_LIMIT = 25 * 1024 * 1024;
|
|
26
|
+
/** Uploads in flight at once. */
|
|
27
|
+
const PARALLEL = 4;
|
|
28
|
+
const isAddress = (source) => /^https?:\/\//i.test(source);
|
|
29
|
+
/** The file a source names in the repository: its path from the root —
|
|
30
|
+
or, written from the site's root (`/images/a.png`), under `public/`
|
|
31
|
+
or `static/` as the frameworks serve it. Never a file outside the
|
|
32
|
+
repository, links included. */
|
|
33
|
+
export async function locate(root, source) {
|
|
34
|
+
const realRoot = await realpath(root);
|
|
35
|
+
const rel = source.replace(/^\.\//, "");
|
|
36
|
+
const tries = rel.startsWith("/")
|
|
37
|
+
? [rel.slice(1), `public${rel}`, `static${rel}`]
|
|
38
|
+
: [rel, `public/${rel}`];
|
|
39
|
+
for (const candidate of tries) {
|
|
40
|
+
let real;
|
|
41
|
+
try {
|
|
42
|
+
real = await realpath(path.resolve(realRoot, candidate));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!real.startsWith(realRoot + path.sep))
|
|
48
|
+
continue;
|
|
49
|
+
if ((await stat(real)).isFile())
|
|
50
|
+
return real;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/** Why an upload failed, as Mapled said it. */
|
|
55
|
+
const why = (err) => (err instanceof ApiError || err instanceof CliError ? err.message : "the upload didn't go through. Try again.");
|
|
56
|
+
async function uploadFile(session, f, root, source, run) {
|
|
57
|
+
const file = await locate(root, source);
|
|
58
|
+
if (!file)
|
|
59
|
+
return { source, ok: false, error: "not found in the repository." };
|
|
60
|
+
const mime = TYPES[path.extname(file).toLowerCase()];
|
|
61
|
+
if (!mime)
|
|
62
|
+
return { source, ok: false, error: "isn't a file Media takes: PNG, JPEG, WebP, GIF, SVG, PDF, GLB or glTF." };
|
|
63
|
+
const bytes = await readFile(file);
|
|
64
|
+
const limit = mime.startsWith("image/") ? IMAGE_LIMIT : FILE_LIMIT;
|
|
65
|
+
if (bytes.length > limit)
|
|
66
|
+
return { source, ok: false, error: `is larger than ${limit / 1024 / 1024} MB. Compress it and try again.` };
|
|
67
|
+
if (bytes.length === 0)
|
|
68
|
+
return { source, ok: false, error: "is empty." };
|
|
69
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
70
|
+
try {
|
|
71
|
+
const signed = await session.send("POST", "/v1/agent/media/sign", { fileName: path.basename(file), mime, sizeBytes: bytes.length, sha256, ...run });
|
|
72
|
+
if (signed.asset)
|
|
73
|
+
return { source, ok: true, asset: signed.asset, deduplicated: true };
|
|
74
|
+
// R2's presigned address, or the API's own sink in development
|
|
75
|
+
const target = new URL(signed.uploadUrl, session.conn.api).href;
|
|
76
|
+
let put;
|
|
77
|
+
try {
|
|
78
|
+
put = await f(target, { method: "PUT", headers: signed.headers ?? { "Content-Type": mime }, body: new Uint8Array(bytes) });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { source, ok: false, error: "the upload didn't reach storage. Check your connection and try again." };
|
|
82
|
+
}
|
|
83
|
+
if (!put.ok)
|
|
84
|
+
return { source, ok: false, error: `storage refused the upload (${put.status}). Try again.` };
|
|
85
|
+
const done = await session.send("POST", "/v1/agent/media/complete", { ticket: signed.ticket, ...run });
|
|
86
|
+
return { source, ok: true, asset: done.asset, deduplicated: done.deduplicated };
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return { source, ok: false, error: why(err) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** One source into Media: a file of the repository at `root`, or a
|
|
93
|
+
public address. */
|
|
94
|
+
export async function uploadSource(session, f, root, source, runId) {
|
|
95
|
+
const run = runId ? { runId, source } : null;
|
|
96
|
+
if (!isAddress(source))
|
|
97
|
+
return uploadFile(session, f, root, source, run);
|
|
98
|
+
try {
|
|
99
|
+
const done = await session.send("POST", "/v1/agent/media/import", { url: source, ...run });
|
|
100
|
+
return { source, ok: true, asset: done.asset, deduplicated: done.deduplicated };
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
return { source, ok: false, error: why(err) };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Every source, a few at a time, in the order given. */
|
|
107
|
+
export async function uploadAll(session, f, root, sources, runId) {
|
|
108
|
+
const results = new Array(sources.length);
|
|
109
|
+
let next = 0;
|
|
110
|
+
const worker = async () => {
|
|
111
|
+
while (next < sources.length) {
|
|
112
|
+
const i = next++;
|
|
113
|
+
results[i] = await uploadSource(session, f, root, sources[i], runId);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
await Promise.all(Array.from({ length: Math.min(PARALLEL, sources.length) }, worker));
|
|
117
|
+
return results;
|
|
118
|
+
}
|
|
119
|
+
/** The sources of a setup run's plan that aren't in Mapled yet. */
|
|
120
|
+
export async function pendingOf(session, runId) {
|
|
121
|
+
const answer = await session.get(`/v1/agent/runs/${encodeURIComponent(runId)}`);
|
|
122
|
+
return {
|
|
123
|
+
status: answer.run?.status ?? "",
|
|
124
|
+
pending: answer.run?.media?.pending ?? [],
|
|
125
|
+
total: answer.run?.media?.total ?? 0,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** One line per upload: what it became, or why it didn't. */
|
|
129
|
+
export function uploadLine(r) {
|
|
130
|
+
return r.ok
|
|
131
|
+
? `✓ ${r.source} → ${r.asset.id}${r.deduplicated ? " (already in Media)" : ""}`
|
|
132
|
+
: `✗ ${r.source}: ${r.error}`;
|
|
133
|
+
}
|
package/dist/oauth.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type CallbackView } from "./callbackPage.js";
|
|
1
2
|
/** The CLI signs in as an OAuth 2.1 public client of the Mapled API —
|
|
2
3
|
the same authorization server the remote MCP uses (S-54): dynamic
|
|
3
4
|
registration, PKCE, consent on app.mapled.io, a loopback redirect
|
|
@@ -17,6 +18,13 @@ export type AuthorizationCode = {
|
|
|
17
18
|
verifier: string;
|
|
18
19
|
redirectUri: string;
|
|
19
20
|
};
|
|
21
|
+
/** The browser tab that brought the code waits on this: the caller says
|
|
22
|
+
how the sign-in ended once it knows (the project, where to go on). */
|
|
23
|
+
export type SignInTab = {
|
|
24
|
+
show: (view: CallbackView) => void;
|
|
25
|
+
};
|
|
26
|
+
/** How long the tab waits for `show` before it hands over on its own. */
|
|
27
|
+
export declare const HOLD_MS = 30000;
|
|
20
28
|
export declare function pkce(): {
|
|
21
29
|
verifier: string;
|
|
22
30
|
challenge: string;
|
|
@@ -39,8 +47,12 @@ export type AuthorizeDeps = {
|
|
|
39
47
|
/** The authorization code flow with a loopback redirect: a one-off
|
|
40
48
|
server on 127.0.0.1 receives the code once the person approves on
|
|
41
49
|
app.mapled.io. Rejects on denial, on timeout, and never on a state
|
|
42
|
-
that isn't this run's.
|
|
43
|
-
|
|
50
|
+
that isn't this run's. The tab that brought the code is answered by
|
|
51
|
+
the caller through `tab.show` — after the exchange, when the project
|
|
52
|
+
is known — or, failing that, after `HOLD_MS`. */
|
|
53
|
+
export declare function authorize(api: string, clientId: string, deps: AuthorizeDeps): Promise<AuthorizationCode & {
|
|
54
|
+
tab: SignInTab;
|
|
55
|
+
}>;
|
|
44
56
|
export declare function exchangeCode(api: string, clientId: string, grant: AuthorizationCode, f: Fetch): Promise<TokenSet>;
|
|
45
57
|
export declare function refreshTokens(api: string, clientId: string, refreshToken: string, f: Fetch): Promise<TokenSet>;
|
|
46
58
|
/** Ends the connection (RFC 7009). Best effort — the local copy goes either way. */
|
package/dist/oauth.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { callbackPage } from "./callbackPage.js";
|
|
3
4
|
import { CliError } from "./errors.js";
|
|
4
5
|
/** The CLI signs in as an OAuth 2.1 public client of the Mapled API —
|
|
5
6
|
the same authorization server the remote MCP uses (S-54): dynamic
|
|
@@ -9,6 +10,8 @@ import { CliError } from "./errors.js";
|
|
|
9
10
|
export const CLIENT_NAME = "Mapled CLI";
|
|
10
11
|
export const REDIRECT_PATH = "/callback";
|
|
11
12
|
export const SCOPE = "mapled";
|
|
13
|
+
/** How long the tab waits for `show` before it hands over on its own. */
|
|
14
|
+
export const HOLD_MS = 30_000;
|
|
12
15
|
export function pkce() {
|
|
13
16
|
const verifier = randomBytes(48).toString("base64url");
|
|
14
17
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
@@ -83,16 +86,26 @@ export async function clientKnown(api, clientId, f) {
|
|
|
83
86
|
return false;
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
|
-
function page(res,
|
|
87
|
-
// Fixed strings of our own
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
function page(res, view) {
|
|
90
|
+
// Fixed strings of our own and the project's name, escaped — nothing
|
|
91
|
+
// from the query string is rendered.
|
|
92
|
+
const { status, html, csp } = callbackPage(view);
|
|
93
|
+
res.writeHead(status, {
|
|
94
|
+
"content-type": "text/html; charset=utf-8",
|
|
95
|
+
"cache-control": "no-store",
|
|
96
|
+
"content-security-policy": csp,
|
|
97
|
+
"referrer-policy": "no-referrer",
|
|
98
|
+
"x-content-type-options": "nosniff",
|
|
99
|
+
connection: "close",
|
|
100
|
+
});
|
|
101
|
+
res.end(html);
|
|
91
102
|
}
|
|
92
103
|
/** The authorization code flow with a loopback redirect: a one-off
|
|
93
104
|
server on 127.0.0.1 receives the code once the person approves on
|
|
94
105
|
app.mapled.io. Rejects on denial, on timeout, and never on a state
|
|
95
|
-
that isn't this run's.
|
|
106
|
+
that isn't this run's. The tab that brought the code is answered by
|
|
107
|
+
the caller through `tab.show` — after the exchange, when the project
|
|
108
|
+
is known — or, failing that, after `HOLD_MS`. */
|
|
96
109
|
export function authorize(api, clientId, deps) {
|
|
97
110
|
const { verifier, challenge } = pkce();
|
|
98
111
|
const state = randomBytes(16).toString("base64url");
|
|
@@ -107,12 +120,12 @@ export function authorize(api, clientId, deps) {
|
|
|
107
120
|
}
|
|
108
121
|
if (url.searchParams.get("state") !== state) {
|
|
109
122
|
// Not this run's redirect — keep waiting for the real one.
|
|
110
|
-
page(res,
|
|
123
|
+
page(res, { kind: "expired" });
|
|
111
124
|
return;
|
|
112
125
|
}
|
|
113
126
|
const error = url.searchParams.get("error");
|
|
114
127
|
if (error) {
|
|
115
|
-
page(res,
|
|
128
|
+
page(res, { kind: "cancelled" });
|
|
116
129
|
finish(() => reject(new CliError(error === "access_denied"
|
|
117
130
|
? "Sign-in was denied in the browser."
|
|
118
131
|
: `Sign-in failed: ${url.searchParams.get("error_description") ?? error}`)));
|
|
@@ -120,11 +133,21 @@ export function authorize(api, clientId, deps) {
|
|
|
120
133
|
}
|
|
121
134
|
const code = url.searchParams.get("code");
|
|
122
135
|
if (!code) {
|
|
123
|
-
page(res,
|
|
136
|
+
page(res, { kind: "incomplete" });
|
|
124
137
|
return;
|
|
125
138
|
}
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
// the tab waits for the outcome; the server stops listening now
|
|
140
|
+
let shown = false;
|
|
141
|
+
const show = (view) => {
|
|
142
|
+
if (shown)
|
|
143
|
+
return;
|
|
144
|
+
shown = true;
|
|
145
|
+
clearTimeout(hold);
|
|
146
|
+
page(res, view);
|
|
147
|
+
};
|
|
148
|
+
const hold = setTimeout(() => show({ kind: "handed-over" }), HOLD_MS);
|
|
149
|
+
hold.unref?.();
|
|
150
|
+
finish(() => resolve({ code, verifier, redirectUri, tab: { show } }));
|
|
128
151
|
});
|
|
129
152
|
const timer = setTimeout(() => finish(() => reject(new CliError("Timed out waiting for the browser. Run `mapled auth login` again."))), deps.timeoutMs ?? 5 * 60_000);
|
|
130
153
|
function finish(then) {
|
package/dist/pin.js
CHANGED
|
@@ -14,6 +14,14 @@ function pinSubField(s) {
|
|
|
14
14
|
out.helpText = s.helpText ?? null;
|
|
15
15
|
out.options = s.options ?? null;
|
|
16
16
|
out.sensitive = s.sensitive === true;
|
|
17
|
+
// a component type's group sub-field (§45.4) is in the schema hash: only it carries one
|
|
18
|
+
if (s.type === "group" && s.group) {
|
|
19
|
+
out.group = { repeatable: s.group.repeatable, ...(s.group.maxItems ? { maxItems: s.group.maxItems } : {}), fields: s.group.fields.map(pinSubField) };
|
|
20
|
+
}
|
|
21
|
+
// and its relation sub-field's collection (§45.4)
|
|
22
|
+
if (s.type === "relation" && s.relation) {
|
|
23
|
+
out.relation = { target: s.relation.target, cardinality: s.relation.cardinality, ...(s.relation.onDelete ? { onDelete: s.relation.onDelete } : {}) };
|
|
24
|
+
}
|
|
17
25
|
return out;
|
|
18
26
|
}
|
|
19
27
|
function pinField(f) {
|
|
@@ -157,6 +165,47 @@ function diffCommon(before, after, names, push) {
|
|
|
157
165
|
push("changed", "no longer sensitive — delivered to the site now", "safe");
|
|
158
166
|
}
|
|
159
167
|
}
|
|
168
|
+
/** A group's own changes — one item or a list, its limit, its
|
|
169
|
+
sub-fields removed, changed and added — the sub-fields under
|
|
170
|
+
`under.sub`. */
|
|
171
|
+
function diffGroup(before, after, under, names, push) {
|
|
172
|
+
if (before.repeatable !== after.repeatable) {
|
|
173
|
+
push(under, "changed", before.repeatable ? "repeatable → single item" : "single item → repeatable", "breaking");
|
|
174
|
+
}
|
|
175
|
+
if ((before.maxItems ?? null) !== (after.maxItems ?? null)) {
|
|
176
|
+
const limit = (n) => (n ? `up to ${n} items` : "no item limit");
|
|
177
|
+
push(under, "changed", `${limit(before.maxItems)} → ${limit(after.maxItems)}`, "safe");
|
|
178
|
+
}
|
|
179
|
+
const afterSubs = new Map(after.fields.map((s) => [s.key, s]));
|
|
180
|
+
const beforeSubs = new Map(before.fields.map((s) => [s.key, s]));
|
|
181
|
+
for (const bs of before.fields) {
|
|
182
|
+
const as = afterSubs.get(bs.key);
|
|
183
|
+
const sub = `${under}.${bs.key}`;
|
|
184
|
+
if (!as) {
|
|
185
|
+
push(sub, "removed", `removed — ${describeType(bs, names)}`, "breaking");
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
diffCommon(bs, as, names, (op, detail, severity) => push(sub, op, detail, severity));
|
|
189
|
+
}
|
|
190
|
+
for (const as of after.fields) {
|
|
191
|
+
if (beforeSubs.has(as.key))
|
|
192
|
+
continue;
|
|
193
|
+
push(`${under}.${as.key}`, "added", `added — ${describeType(as, names)}, ${as.required ? "required" : "optional"}`, "safe");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** A relation's own changes — a field's, or a block's relation
|
|
197
|
+
sub-field's (§45.4): where it links and how many are breaking, what a
|
|
198
|
+
delete does is safe. */
|
|
199
|
+
function diffRelation(before, after, names, push) {
|
|
200
|
+
if (before.target !== after.target) {
|
|
201
|
+
push("changed", `relation to ${names.get(before.target) ?? before.target} → ${names.get(after.target) ?? after.target}`, "breaking");
|
|
202
|
+
}
|
|
203
|
+
if (before.cardinality !== after.cardinality)
|
|
204
|
+
push("changed", `${before.cardinality} → ${after.cardinality}`, "breaking");
|
|
205
|
+
if ((before.onDelete ?? null) !== (after.onDelete ?? null)) {
|
|
206
|
+
push("changed", `on delete ${before.onDelete ?? "default"} → ${after.onDelete ?? "default"}`, "safe");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
160
209
|
function sameJson(a, b) {
|
|
161
210
|
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
162
211
|
}
|
|
@@ -213,16 +262,8 @@ export function diffSchema(before, after, componentTypes = {}) {
|
|
|
213
262
|
if ((bf.localized === true) !== (af.localized === true)) {
|
|
214
263
|
push("changed", af.localized === true ? "now translated — the site still reads the default language" : "no longer translated — the default language's value stays", "safe");
|
|
215
264
|
}
|
|
216
|
-
if (bf.type === "relation" && af.type === "relation" && bf.relation && af.relation)
|
|
217
|
-
|
|
218
|
-
push("changed", `relation to ${names.get(bf.relation.target) ?? bf.relation.target} → ${names.get(af.relation.target) ?? af.relation.target}`, "breaking");
|
|
219
|
-
}
|
|
220
|
-
if (bf.relation.cardinality !== af.relation.cardinality)
|
|
221
|
-
push("changed", `${bf.relation.cardinality} → ${af.relation.cardinality}`, "breaking");
|
|
222
|
-
if ((bf.relation.onDelete ?? null) !== (af.relation.onDelete ?? null)) {
|
|
223
|
-
push("changed", `on delete ${bf.relation.onDelete ?? "default"} → ${af.relation.onDelete ?? "default"}`, "safe");
|
|
224
|
-
}
|
|
225
|
-
}
|
|
265
|
+
if (bf.type === "relation" && af.type === "relation" && bf.relation && af.relation)
|
|
266
|
+
diffRelation(bf.relation, af.relation, names, push);
|
|
226
267
|
if (bf.type === "computed" && af.type === "computed" && bf.computed && af.computed) {
|
|
227
268
|
if (bf.computed.resultType !== af.computed.resultType) {
|
|
228
269
|
push("changed", `${describeType(bf, names)} → ${describeType(af, names)}`, "breaking");
|
|
@@ -232,36 +273,7 @@ export function diffSchema(before, after, componentTypes = {}) {
|
|
|
232
273
|
}
|
|
233
274
|
}
|
|
234
275
|
if (bf.type === "group" && af.type === "group" && bf.group && af.group) {
|
|
235
|
-
|
|
236
|
-
push("changed", bf.group.repeatable ? "repeatable → single item" : "single item → repeatable", "breaking");
|
|
237
|
-
}
|
|
238
|
-
if ((bf.group.maxItems ?? null) !== (af.group.maxItems ?? null)) {
|
|
239
|
-
const limit = (n) => (n ? `up to ${n} items` : "no item limit");
|
|
240
|
-
push("changed", `${limit(bf.group.maxItems)} → ${limit(af.group.maxItems)}`, "safe");
|
|
241
|
-
}
|
|
242
|
-
const afterSubs = new Map(af.group.fields.map((s) => [s.key, s]));
|
|
243
|
-
const beforeSubs = new Map(bf.group.fields.map((s) => [s.key, s]));
|
|
244
|
-
for (const bs of bf.group.fields) {
|
|
245
|
-
const as = afterSubs.get(bs.key);
|
|
246
|
-
const sub = `${under}.${bs.key}`;
|
|
247
|
-
if (!as) {
|
|
248
|
-
changes.push({ ...base, scope: "subfield", field: sub, op: "removed", detail: `removed — ${describeType(bs, names)}`, severity: "breaking" });
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
diffCommon(bs, as, names, (op, detail, severity) => changes.push({ ...base, scope: "subfield", field: sub, op, detail, severity }));
|
|
252
|
-
}
|
|
253
|
-
for (const as of af.group.fields) {
|
|
254
|
-
if (beforeSubs.has(as.key))
|
|
255
|
-
continue;
|
|
256
|
-
changes.push({
|
|
257
|
-
...base,
|
|
258
|
-
scope: "subfield",
|
|
259
|
-
field: `${under}.${as.key}`,
|
|
260
|
-
op: "added",
|
|
261
|
-
detail: `added — ${describeType(as, names)}, ${as.required ? "required" : "optional"}`,
|
|
262
|
-
severity: "safe",
|
|
263
|
-
});
|
|
264
|
-
}
|
|
276
|
+
diffGroup(bf.group, af.group, under, names, (field, op, detail, severity) => changes.push({ ...base, scope: field === under ? "field" : "subfield", field, op, detail, severity }));
|
|
265
277
|
}
|
|
266
278
|
// §45.4: a type the list may no longer hold leaves the site's union; a new one is an item the site doesn't render yet
|
|
267
279
|
if (bf.type === "components" && af.type === "components" && bf.components && af.components) {
|
|
@@ -322,18 +334,26 @@ export function diffSchema(before, after, componentTypes = {}) {
|
|
|
322
334
|
changes.push({ ...base, scope: "component", op: "changed", detail: `name ${q(bt.displayName)} → ${q(at.displayName)}`, severity: "safe" });
|
|
323
335
|
const afterSubs = new Map(at.fields.map((s) => [s.key, s]));
|
|
324
336
|
const beforeSubs = new Map(bt.fields.map((s) => [s.key, s]));
|
|
337
|
+
const push = (field, op, detail, severity) => changes.push({ ...base, scope: "component", field, op, detail, severity });
|
|
325
338
|
for (const bs of bt.fields) {
|
|
326
339
|
const as = afterSubs.get(bs.key);
|
|
327
340
|
if (!as) {
|
|
328
|
-
|
|
341
|
+
push(bs.key, "removed", `removed — ${describeType(bs, names)}`, "breaking");
|
|
329
342
|
continue;
|
|
330
343
|
}
|
|
331
|
-
diffCommon(bs, as, names, (op, detail, severity) =>
|
|
344
|
+
diffCommon(bs, as, names, (op, detail, severity) => push(bs.key, op, detail, severity));
|
|
345
|
+
// a group sub-field's items (§45.4): its own sub-fields under `tiers.name`
|
|
346
|
+
if (bs.type === "group" && as.type === "group" && bs.group && as.group)
|
|
347
|
+
diffGroup(bs.group, as.group, bs.key, names, push);
|
|
348
|
+
// a relation sub-field's collection and cardinality, as a relation field's
|
|
349
|
+
if (bs.type === "relation" && as.type === "relation" && bs.relation && as.relation) {
|
|
350
|
+
diffRelation(bs.relation, as.relation, names, (op, detail, severity) => push(bs.key, op, detail, severity));
|
|
351
|
+
}
|
|
332
352
|
}
|
|
333
353
|
for (const as of at.fields) {
|
|
334
354
|
if (beforeSubs.has(as.key))
|
|
335
355
|
continue;
|
|
336
|
-
|
|
356
|
+
push(as.key, "added", `added — ${describeType(as, names)}, ${as.required ? "required" : "optional"}`, "safe");
|
|
337
357
|
}
|
|
338
358
|
}
|
|
339
359
|
for (const at of typesAfter) {
|
package/dist/scanHtml.d.ts
CHANGED
|
@@ -5,16 +5,23 @@ import type { Schema } from "./schema.js";
|
|
|
5
5
|
`data-mapled-*` attributes declare — read the way the runtime reads
|
|
6
6
|
them (packages/vanilla/src/runtime.ts, `compile`): a path starts at a
|
|
7
7
|
single from the document and at the record inside a list or a record,
|
|
8
|
-
an each repeats the items of a group
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
an each repeats the items of a group or the blocks of a components
|
|
9
|
+
field — inside a block's template, the items of its group sub-field
|
|
10
|
+
(§45.4) — a list's records and a group's items take the first <template>
|
|
11
|
+
without data-mapled-type and a block the first of its type (else that
|
|
12
|
+
one), a list or a record inside a repeated template is never read. The target is the sink the attribute names —
|
|
11
13
|
`markdown` renders rich text, an <img> an image, `bind-href` a link —
|
|
12
|
-
so a field bound where it can't render shows as a mismatch
|
|
14
|
+
so a field bound where it can't render shows as a mismatch; a place
|
|
15
|
+
the runtime doesn't write into (a code element, an attribute off its
|
|
16
|
+
elements, an attribute inside <svg>) binds nothing and earns a note. What the
|
|
13
17
|
runtime can't tell apart — an empty field and a misspelled key — the
|
|
14
18
|
scan can: it has the schema. Nothing on the page runs or leaves the
|
|
15
19
|
machine; only the manifest does, on `bindings push`. */
|
|
20
|
+
/** `html`: the element is in the HTML namespace — not SVG or MathML, where
|
|
21
|
+
the runtime writes no attribute (DOM rule 3). */
|
|
16
22
|
export type HtmlElement = {
|
|
17
23
|
name: string;
|
|
24
|
+
html: boolean;
|
|
18
25
|
attrs: Map<string, string>;
|
|
19
26
|
children: HtmlElement[];
|
|
20
27
|
};
|