@mapled/cli 0.14.2 → 0.14.4
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 +6 -3
- package/dist/args.js +1 -1
- package/dist/commands.d.ts +6 -4
- package/dist/commands.js +30 -13
- package/dist/doctor.d.ts +27 -2
- package/dist/doctor.js +141 -11
- package/dist/index.js +1 -1
- package/dist/integrationHash.d.ts +6 -1
- package/dist/integrationHash.js +2 -0
- package/dist/live.d.ts +10 -0
- package/dist/live.js +13 -0
- package/dist/manifest.d.ts +18 -1
- package/dist/manifest.js +42 -1
- package/dist/pin.d.ts +9 -3
- package/dist/pin.js +71 -5
- package/dist/report.d.ts +1 -0
- package/dist/scan.d.ts +10 -1
- package/dist/scan.js +22 -2
- package/dist/schema.d.ts +16 -0
- package/dist/schema.js +25 -1
- package/dist/types.d.ts +19 -3
- package/dist/types.js +79 -14
- package/dist/vanillaDigests.js +2 -0
- package/package.json +1 -1
package/dist/manifest.js
CHANGED
|
@@ -22,6 +22,7 @@ export const BINDING_TARGETS = [
|
|
|
22
22
|
"form_field",
|
|
23
23
|
"other",
|
|
24
24
|
];
|
|
25
|
+
export const LOCALE_ROUTINGS = ["prefix", "prefix-except-default"];
|
|
25
26
|
export const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/\[\]-]{0,119}$/;
|
|
26
27
|
export const LIMITS = { bindings: 500, pages: 200, notes: 20 };
|
|
27
28
|
/** Field types a target renders without a transform — the API's table
|
|
@@ -91,7 +92,8 @@ export function typeLabel(type) {
|
|
|
91
92
|
}
|
|
92
93
|
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
93
94
|
const isText = (v, max, min = 1) => typeof v === "string" && v.length >= min && v.length <= max;
|
|
94
|
-
const TOP_KEYS = new Set(["framework", "integrationMode", "pages", "bindings", "notes", "capabilities"]);
|
|
95
|
+
const TOP_KEYS = new Set(["framework", "integrationMode", "pages", "bindings", "notes", "capabilities", "locales"]);
|
|
96
|
+
const LOCALE_PARAM = /^[A-Za-z0-9_-]{1,60}$/;
|
|
95
97
|
export const CAPABILITY_KEYS = ["realtime", "releaseRoute"];
|
|
96
98
|
const BINDING_KEYS = new Set(["key", "page", "component", "file", "collection", "field", "target", "required"]);
|
|
97
99
|
/** Parses the file's text into a manifest with only the known fields,
|
|
@@ -271,6 +273,31 @@ export function parseManifest(raw, file) {
|
|
|
271
273
|
manifest.capabilities = kept;
|
|
272
274
|
}
|
|
273
275
|
}
|
|
276
|
+
if (data.locales !== undefined) {
|
|
277
|
+
const loc = data.locales;
|
|
278
|
+
if (!isObject(loc)) {
|
|
279
|
+
error("locales", `"locales" must be an object — { routing, param }.`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
let ok = true;
|
|
283
|
+
for (const key of Object.keys(loc)) {
|
|
284
|
+
if (key !== "routing" && key !== "param") {
|
|
285
|
+
error(`locales.${key}`, `locales.${key} isn't part of the manifest — only routing and param; Mapled refuses the push.`);
|
|
286
|
+
ok = false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (!LOCALE_ROUTINGS.includes(loc.routing)) {
|
|
290
|
+
error("locales.routing", `locales.routing must be "prefix" (every language under its own, /en/…) or "prefix-except-default" (the default language at the root).`);
|
|
291
|
+
ok = false;
|
|
292
|
+
}
|
|
293
|
+
if (loc.param !== undefined && !(typeof loc.param === "string" && LOCALE_PARAM.test(loc.param))) {
|
|
294
|
+
error("locales.param", `locales.param must name the dynamic segment that carries the language, e.g. "locale" for /[locale]/blog/[slug].`);
|
|
295
|
+
ok = false;
|
|
296
|
+
}
|
|
297
|
+
if (ok)
|
|
298
|
+
manifest.locales = { routing: loc.routing, ...(typeof loc.param === "string" ? { param: loc.param } : {}) };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
274
301
|
return { manifest, problems };
|
|
275
302
|
}
|
|
276
303
|
export function indexSchema(schema) {
|
|
@@ -302,6 +329,14 @@ export async function checkManifest(manifest, opts) {
|
|
|
302
329
|
const warn = (p, message) => problems.push({ level: "warning", path: p, message });
|
|
303
330
|
const index = opts.schema ? indexSchema(opts.schema) : null;
|
|
304
331
|
const routes = new Set((manifest.pages ?? []).map((p) => p.route));
|
|
332
|
+
// routes by language (§28.4): a segment the manifest names must be on some page
|
|
333
|
+
if (manifest.locales?.param !== undefined) {
|
|
334
|
+
const marker = `[${manifest.locales.param}]`;
|
|
335
|
+
const all = [...routes, ...manifest.bindings.map((b) => b.page)];
|
|
336
|
+
if (all.length > 0 && !all.some((r) => r.split("?")[0].split("/").includes(marker))) {
|
|
337
|
+
warn("locales.param", `locales.param names "${marker}", but no page route has that segment — the language then goes nowhere; leave param out for Mapled to put the language before the page.`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
305
340
|
for (const [i, p] of (manifest.pages ?? []).entries()) {
|
|
306
341
|
if (p.file && !(await opts.fileExists(p.file))) {
|
|
307
342
|
warn(`pages[${i}].file`, `pages[${i}] (${p.route}): ${p.file} doesn't exist in the repository.`);
|
|
@@ -381,8 +416,13 @@ export function orderManifest(manifest) {
|
|
|
381
416
|
bindings: manifest.bindings.map(orderBinding),
|
|
382
417
|
...(manifest.notes && manifest.notes.length > 0 ? { notes: [...manifest.notes] } : {}),
|
|
383
418
|
...(manifest.capabilities ? { capabilities: orderCapabilities(manifest.capabilities) } : {}),
|
|
419
|
+
...(manifest.locales ? { locales: { routing: manifest.locales.routing, ...(manifest.locales.param !== undefined ? { param: manifest.locales.param } : {}) } } : {}),
|
|
384
420
|
};
|
|
385
421
|
}
|
|
422
|
+
/** Whether two manifests route languages the same way (missing on both is the same). */
|
|
423
|
+
export function sameLocales(a, b) {
|
|
424
|
+
return (a?.routing ?? null) === (b?.routing ?? null) && (a?.param ?? null) === (b?.param ?? null);
|
|
425
|
+
}
|
|
386
426
|
function orderCapabilities(c) {
|
|
387
427
|
return {
|
|
388
428
|
...(c.realtime !== undefined ? { realtime: c.realtime } : {}),
|
|
@@ -434,5 +474,6 @@ export function compareManifests(local, remote) {
|
|
|
434
474
|
onlyRemote,
|
|
435
475
|
changed,
|
|
436
476
|
capabilitiesDiffer: !sameCapabilities(local.capabilities, remote.capabilities),
|
|
477
|
+
localesDiffer: !sameLocales(local.locales, remote.locales),
|
|
437
478
|
};
|
|
438
479
|
}
|
package/dist/pin.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Schema, type SchemaCollection } from "./schema.js";
|
|
1
|
+
import { type Schema, type SchemaCollection, type SchemaComponentType } from "./schema.js";
|
|
2
2
|
/** mapled/schema.json — the repository's pin of the schema it was last
|
|
3
3
|
synced with (§21.5). `mapled schema diff` compares it with the live
|
|
4
4
|
schema and says which changes a site that READS the content must
|
|
@@ -9,6 +9,8 @@ export type SchemaPin = {
|
|
|
9
9
|
project: string;
|
|
10
10
|
hash: string;
|
|
11
11
|
collections: SchemaCollection[];
|
|
12
|
+
/** the component types (§45.4) — only when the project has some, so older pins read as before */
|
|
13
|
+
componentTypes?: SchemaComponentType[];
|
|
12
14
|
};
|
|
13
15
|
/** The schema reduced to what the pin keeps — the live answer of
|
|
14
16
|
GET /v1/agent/schema without ids, positions and counters. */
|
|
@@ -22,7 +24,8 @@ export declare function writePin(dir: string, pin: SchemaPin): Promise<string>;
|
|
|
22
24
|
export type Severity = "breaking" | "safe";
|
|
23
25
|
export type ChangeOp = "added" | "removed" | "changed" | "renamed";
|
|
24
26
|
export type SchemaChange = {
|
|
25
|
-
|
|
27
|
+
/** `component`: a component type (§45.4) — `collection` holds its key */
|
|
28
|
+
scope: "collection" | "field" | "subfield" | "component";
|
|
26
29
|
collection: string;
|
|
27
30
|
collectionName: string;
|
|
28
31
|
/** The field key; "group.sub" for a sub-field. */
|
|
@@ -37,7 +40,10 @@ export type SchemaChange = {
|
|
|
37
40
|
(§33.5) is safe while the old one is still an alias of the field —
|
|
38
41
|
the site keeps reading it — and the rest of the field is compared
|
|
39
42
|
under its new key; once the alias is removed it is a removed field. */
|
|
40
|
-
export declare function diffSchema(before: SchemaCollection[], after: SchemaCollection[]
|
|
43
|
+
export declare function diffSchema(before: SchemaCollection[], after: SchemaCollection[], componentTypes?: {
|
|
44
|
+
before?: SchemaComponentType[];
|
|
45
|
+
after?: SchemaComponentType[];
|
|
46
|
+
}): SchemaChange[];
|
|
41
47
|
/** The diff as terminal lines: one heading per collection, one line per
|
|
42
48
|
change, the severity in the last column. */
|
|
43
49
|
export declare function formatChanges(changes: SchemaChange[], paint?: (severity: Severity, text: string) => string): string[];
|
package/dist/pin.js
CHANGED
|
@@ -50,6 +50,10 @@ function pinField(f) {
|
|
|
50
50
|
// translation is part of the schema hash too (§28.2)
|
|
51
51
|
if (f.localized === true)
|
|
52
52
|
out.localized = true;
|
|
53
|
+
// and so are the component types a field allows (§45.4)
|
|
54
|
+
if (f.type === "components" && f.components) {
|
|
55
|
+
out.components = { allowed: [...f.components.allowed], min: f.components.min ?? null, max: f.components.max ?? null };
|
|
56
|
+
}
|
|
53
57
|
if (f.aliases && f.aliases.length > 0)
|
|
54
58
|
out.aliases = [...f.aliases];
|
|
55
59
|
return out;
|
|
@@ -65,7 +69,10 @@ export function pinSchema(schema, project) {
|
|
|
65
69
|
mode: c.mode,
|
|
66
70
|
fields: c.fields.map(pinField),
|
|
67
71
|
}));
|
|
68
|
-
|
|
72
|
+
const componentTypes = (schema.componentTypes ?? []).map((t) => ({ key: t.key, displayName: t.displayName, fields: t.fields.map(pinSubField) }));
|
|
73
|
+
if (componentTypes.length === 0)
|
|
74
|
+
return { project, hash: schemaHash({ collections }), collections };
|
|
75
|
+
return { project, hash: schemaHash({ collections, componentTypes }), collections, componentTypes };
|
|
69
76
|
}
|
|
70
77
|
export function parsePin(raw, file) {
|
|
71
78
|
let data;
|
|
@@ -84,7 +91,8 @@ export function parsePin(raw, file) {
|
|
|
84
91
|
collections.some((c) => !c || typeof c !== "object" || typeof c.key !== "string" || !Array.isArray(c.fields))) {
|
|
85
92
|
throw new CliError(`${file} isn't a schema pin written by \`mapled schema pull\` — delete it and pull again.`);
|
|
86
93
|
}
|
|
87
|
-
|
|
94
|
+
const componentTypes = Array.isArray(obj.componentTypes) ? obj.componentTypes : undefined;
|
|
95
|
+
return pinSchema({ collections: collections, ...(componentTypes ? { componentTypes } : {}) }, obj.project);
|
|
88
96
|
}
|
|
89
97
|
export async function readPin(dir) {
|
|
90
98
|
const file = path.join(dir, SCHEMA_FILE);
|
|
@@ -109,6 +117,9 @@ function describeType(f, names) {
|
|
|
109
117
|
const target = names.get(f.relation.target) ?? f.relation.target;
|
|
110
118
|
return `relation to ${target}${f.relation.cardinality === "many" ? " (many)" : ""}`;
|
|
111
119
|
}
|
|
120
|
+
if (f.type === "components" && "components" in f && f.components) {
|
|
121
|
+
return `components of ${f.components.allowed.join(", ")}`;
|
|
122
|
+
}
|
|
112
123
|
if (f.type === "group" && "group" in f && f.group) {
|
|
113
124
|
return `group of ${f.group.fields.length} field${f.group.fields.length === 1 ? "" : "s"}${f.group.repeatable ? ", repeatable" : ""}`;
|
|
114
125
|
}
|
|
@@ -155,7 +166,7 @@ function sameJson(a, b) {
|
|
|
155
166
|
(§33.5) is safe while the old one is still an alias of the field —
|
|
156
167
|
the site keeps reading it — and the rest of the field is compared
|
|
157
168
|
under its new key; once the alias is removed it is a removed field. */
|
|
158
|
-
export function diffSchema(before, after) {
|
|
169
|
+
export function diffSchema(before, after, componentTypes = {}) {
|
|
159
170
|
const changes = [];
|
|
160
171
|
const names = new Map();
|
|
161
172
|
for (const c of before)
|
|
@@ -252,6 +263,19 @@ export function diffSchema(before, after) {
|
|
|
252
263
|
});
|
|
253
264
|
}
|
|
254
265
|
}
|
|
266
|
+
// §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
|
+
if (bf.type === "components" && af.type === "components" && bf.components && af.components) {
|
|
268
|
+
for (const k of bf.components.allowed)
|
|
269
|
+
if (!af.components.allowed.includes(k))
|
|
270
|
+
push("changed", `no longer holds ${q(k)} components`, "breaking");
|
|
271
|
+
for (const k of af.components.allowed)
|
|
272
|
+
if (!bf.components.allowed.includes(k))
|
|
273
|
+
push("changed", `may hold ${q(k)} components now`, "safe");
|
|
274
|
+
const limit = (lo, hi) => [lo ? `at least ${lo}` : null, hi ? `up to ${hi}` : null].filter((x) => x !== null).join(", ") || "no item limit";
|
|
275
|
+
if ((bf.components.min ?? null) !== (af.components.min ?? null) || (bf.components.max ?? null) !== (af.components.max ?? null)) {
|
|
276
|
+
push("changed", `${limit(bf.components.min, bf.components.max)} → ${limit(af.components.min, af.components.max)} items`, "safe");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
255
279
|
if (!sameJson(bf.validation, af.validation))
|
|
256
280
|
push("changed", "validation changed", "safe");
|
|
257
281
|
if (!sameJson(bf.defaultValue, af.defaultValue))
|
|
@@ -282,6 +306,48 @@ export function diffSchema(before, after) {
|
|
|
282
306
|
severity: "safe",
|
|
283
307
|
});
|
|
284
308
|
}
|
|
309
|
+
// the component types (§45.4): an item of a type is typed by the type's sub-fields
|
|
310
|
+
const typesBefore = componentTypes.before ?? [];
|
|
311
|
+
const typesAfter = componentTypes.after ?? [];
|
|
312
|
+
const afterTypes = new Map(typesAfter.map((t) => [t.key, t]));
|
|
313
|
+
const beforeTypes = new Map(typesBefore.map((t) => [t.key, t]));
|
|
314
|
+
for (const bt of typesBefore) {
|
|
315
|
+
const at = afterTypes.get(bt.key);
|
|
316
|
+
const base = { collection: bt.key, collectionName: `${at?.displayName ?? bt.displayName} (component)` };
|
|
317
|
+
if (!at) {
|
|
318
|
+
changes.push({ ...base, scope: "component", op: "removed", detail: "component type removed", severity: "breaking" });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (bt.displayName !== at.displayName)
|
|
322
|
+
changes.push({ ...base, scope: "component", op: "changed", detail: `name ${q(bt.displayName)} → ${q(at.displayName)}`, severity: "safe" });
|
|
323
|
+
const afterSubs = new Map(at.fields.map((s) => [s.key, s]));
|
|
324
|
+
const beforeSubs = new Map(bt.fields.map((s) => [s.key, s]));
|
|
325
|
+
for (const bs of bt.fields) {
|
|
326
|
+
const as = afterSubs.get(bs.key);
|
|
327
|
+
if (!as) {
|
|
328
|
+
changes.push({ ...base, scope: "component", field: bs.key, op: "removed", detail: `removed — ${describeType(bs, names)}`, severity: "breaking" });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
diffCommon(bs, as, names, (op, detail, severity) => changes.push({ ...base, scope: "component", field: bs.key, op, detail, severity }));
|
|
332
|
+
}
|
|
333
|
+
for (const as of at.fields) {
|
|
334
|
+
if (beforeSubs.has(as.key))
|
|
335
|
+
continue;
|
|
336
|
+
changes.push({ ...base, scope: "component", field: as.key, op: "added", detail: `added — ${describeType(as, names)}, ${as.required ? "required" : "optional"}`, severity: "safe" });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const at of typesAfter) {
|
|
340
|
+
if (beforeTypes.has(at.key))
|
|
341
|
+
continue;
|
|
342
|
+
changes.push({
|
|
343
|
+
collection: at.key,
|
|
344
|
+
collectionName: `${at.displayName} (component)`,
|
|
345
|
+
scope: "component",
|
|
346
|
+
op: "added",
|
|
347
|
+
detail: `new component type — ${at.fields.length} field${at.fields.length === 1 ? "" : "s"}`,
|
|
348
|
+
severity: "safe",
|
|
349
|
+
});
|
|
350
|
+
}
|
|
285
351
|
return changes;
|
|
286
352
|
}
|
|
287
353
|
const GLYPH = { added: "+", removed: "-", changed: "~", renamed: ">" };
|
|
@@ -303,14 +369,14 @@ export function formatChanges(changes, paint = (_, t) => t) {
|
|
|
303
369
|
for (const key of order) {
|
|
304
370
|
const list = byCollection.get(key);
|
|
305
371
|
const name = list[0].collectionName;
|
|
306
|
-
const whole = list.find((c) => c.scope === "collection" && c.op !== "changed");
|
|
372
|
+
const whole = list.find((c) => (c.scope === "collection" || (c.scope === "component" && !c.field)) && c.op !== "changed");
|
|
307
373
|
if (whole) {
|
|
308
374
|
lines.push(`${GLYPH[whole.op]} ${name} (${key}) ${whole.detail.padEnd(detailWidth)} ${paint(whole.severity, whole.severity)}`);
|
|
309
375
|
continue;
|
|
310
376
|
}
|
|
311
377
|
lines.push(`${name} (${key})`);
|
|
312
378
|
for (const c of list) {
|
|
313
|
-
const label = c.scope === "collection" ? "(collection)" : c.field;
|
|
379
|
+
const label = c.scope === "collection" ? "(collection)" : c.scope === "component" && !c.field ? "(component)" : c.field;
|
|
314
380
|
lines.push(` ${GLYPH[c.op]} ${label.padEnd(width)} ${c.detail.padEnd(detailWidth)} ${paint(c.severity, c.severity)}`);
|
|
315
381
|
}
|
|
316
382
|
}
|
package/dist/report.d.ts
CHANGED
package/dist/scan.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type * as TS from "typescript";
|
|
2
|
-
import { type Manifest, type ManifestBinding, type ManifestCapabilities, type ManifestPage } from "./manifest.js";
|
|
2
|
+
import { type Manifest, type ManifestBinding, type ManifestCapabilities, type ManifestLocales, type ManifestPage } from "./manifest.js";
|
|
3
3
|
import type { Schema } from "./schema.js";
|
|
4
4
|
/** `mapled scan` (§19.5, §21.4 — the standalone scanner of wave 2): reads
|
|
5
5
|
the site's source and writes down what the code reads from Mapled —
|
|
@@ -39,7 +39,14 @@ export type ScanResult = {
|
|
|
39
39
|
notes: string[];
|
|
40
40
|
/** what the code says the site can do (live updates of a Next.js site); undefined keeps the manifest's */
|
|
41
41
|
capabilities?: ManifestCapabilities;
|
|
42
|
+
/** how the routes carry the language (§28.4), when a leading dynamic segment names it; undefined keeps the manifest's */
|
|
43
|
+
locales?: ManifestLocales;
|
|
42
44
|
};
|
|
45
|
+
/** Routes by language from the file conventions (§28.4): a dynamic segment
|
|
46
|
+
named for the language at the front of a route (`/[locale]/…`,
|
|
47
|
+
`/[lang]/…`) — every language then lives under its own prefix, and
|
|
48
|
+
the segment is the manifest's `param`. Null when no route starts so. */
|
|
49
|
+
export declare function detectLocales(routes: Iterable<string>): ManifestLocales | null;
|
|
43
50
|
export declare const SDK_METHODS: Set<string>;
|
|
44
51
|
/** The repository's TypeScript, when it has one; the CLI ships none. */
|
|
45
52
|
export declare function loadTypeScript(dir: string): TypeScriptModule | null;
|
|
@@ -90,6 +97,8 @@ export type MergeResult = {
|
|
|
90
97
|
dropped: string[];
|
|
91
98
|
/** the scan changed what an existing manifest says the site can do */
|
|
92
99
|
capabilitiesChanged: boolean;
|
|
100
|
+
/** the scan changed how an existing manifest routes languages */
|
|
101
|
+
localesChanged: boolean;
|
|
93
102
|
};
|
|
94
103
|
/** The scan's bindings replace the ones with the same key; bindings the
|
|
95
104
|
scanner can't see (an agent's, hand-written) stay unless pruned. */
|
package/dist/scan.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { readdir, readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { sameCapabilities, targetFor } from "./manifest.js";
|
|
4
|
+
import { sameCapabilities, sameLocales, targetFor } from "./manifest.js";
|
|
5
|
+
/** The segment names a site gives the language at the front of its routes. */
|
|
6
|
+
const LOCALE_SEGMENTS = new Set(["locale", "lang", "lng", "language"]);
|
|
7
|
+
/** Routes by language from the file conventions (§28.4): a dynamic segment
|
|
8
|
+
named for the language at the front of a route (`/[locale]/…`,
|
|
9
|
+
`/[lang]/…`) — every language then lives under its own prefix, and
|
|
10
|
+
the segment is the manifest's `param`. Null when no route starts so. */
|
|
11
|
+
export function detectLocales(routes) {
|
|
12
|
+
for (const route of routes) {
|
|
13
|
+
const first = route.split("/")[1];
|
|
14
|
+
const m = first ? /^\[([A-Za-z0-9_-]{1,60})\]$/.exec(first) : null;
|
|
15
|
+
if (m && LOCALE_SEGMENTS.has(m[1].toLowerCase()))
|
|
16
|
+
return { routing: "prefix", param: m[1] };
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
5
20
|
const SKIP_DIRS = new Set([
|
|
6
21
|
"node_modules",
|
|
7
22
|
".git",
|
|
@@ -1398,6 +1413,7 @@ export async function scanRepository(dir, opts) {
|
|
|
1398
1413
|
if (!opts.ts) {
|
|
1399
1414
|
notes.push("TypeScript isn't installed in this repository, so fields weren't traced (collections only) — npm install -D typescript and scan again.");
|
|
1400
1415
|
}
|
|
1416
|
+
const locales = detectLocales(routes.pages.values());
|
|
1401
1417
|
return {
|
|
1402
1418
|
framework,
|
|
1403
1419
|
parser: opts.ts ? "typescript" : "tokens",
|
|
@@ -1405,6 +1421,7 @@ export async function scanRepository(dir, opts) {
|
|
|
1405
1421
|
pages: uniquePages,
|
|
1406
1422
|
bindings: sortedBindings,
|
|
1407
1423
|
notes,
|
|
1424
|
+
...(locales ? { locales } : {}),
|
|
1408
1425
|
};
|
|
1409
1426
|
}
|
|
1410
1427
|
/** The scan's bindings replace the ones with the same key; bindings the
|
|
@@ -1451,7 +1468,10 @@ export function mergeManifest(existing, scan, opts) {
|
|
|
1451
1468
|
bindings: out,
|
|
1452
1469
|
...(existing?.notes && existing.notes.length > 0 ? { notes: existing.notes } : {}),
|
|
1453
1470
|
...(scan.capabilities ? { capabilities: scan.capabilities } : existing?.capabilities ? { capabilities: existing.capabilities } : {}),
|
|
1471
|
+
// the scan's routes by language, else the manifest's (written by hand or by the agent)
|
|
1472
|
+
...(scan.locales ? { locales: scan.locales } : existing?.locales ? { locales: existing.locales } : {}),
|
|
1454
1473
|
};
|
|
1455
1474
|
const capabilitiesChanged = existing !== null && !sameCapabilities(existing.capabilities, manifest.capabilities);
|
|
1456
|
-
|
|
1475
|
+
const localesChanged = existing !== null && !sameLocales(existing.locales, manifest.locales);
|
|
1476
|
+
return { manifest, added, changed, unchanged, kept, dropped, capabilitiesChanged, localesChanged };
|
|
1457
1477
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -44,6 +44,20 @@ export type SchemaField = {
|
|
|
44
44
|
/** Translated (§28.2): the field holds a value per language; the site
|
|
45
45
|
reads the default language's. Absent on older servers. */
|
|
46
46
|
localized?: boolean;
|
|
47
|
+
/** Components (§45.4): the component types the field allows (keys of
|
|
48
|
+
`componentTypes`) and how many items it takes. Only on a components field. */
|
|
49
|
+
components?: {
|
|
50
|
+
allowed: string[];
|
|
51
|
+
min?: number | null;
|
|
52
|
+
max?: number | null;
|
|
53
|
+
} | null;
|
|
54
|
+
};
|
|
55
|
+
/** A component type (§45.4): the key an item names in `_type`, the name,
|
|
56
|
+
the sub-fields in a group's shape. */
|
|
57
|
+
export type SchemaComponentType = {
|
|
58
|
+
key: string;
|
|
59
|
+
displayName: string;
|
|
60
|
+
fields: SchemaSubField[];
|
|
47
61
|
};
|
|
48
62
|
export type SchemaCollection = {
|
|
49
63
|
key: string;
|
|
@@ -55,6 +69,8 @@ export type SchemaCollection = {
|
|
|
55
69
|
};
|
|
56
70
|
export type Schema = {
|
|
57
71
|
collections: SchemaCollection[];
|
|
72
|
+
/** the project's component types, in their order — absent on older servers */
|
|
73
|
+
componentTypes?: SchemaComponentType[];
|
|
58
74
|
};
|
|
59
75
|
/** Twelve hex characters over everything the generated types depend on. */
|
|
60
76
|
export declare function schemaHash(schema: Schema): string;
|
package/dist/schema.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
/** Twelve hex characters over everything the generated types depend on. */
|
|
3
3
|
export function schemaHash(schema) {
|
|
4
|
-
const
|
|
4
|
+
const collections = schema.collections.map((c) => ({
|
|
5
5
|
key: c.key,
|
|
6
6
|
displayName: c.displayName,
|
|
7
7
|
kind: c.kind,
|
|
@@ -33,7 +33,31 @@ export function schemaHash(schema) {
|
|
|
33
33
|
...(f.type === "computed" && f.computed ? { computed: { expression: f.computed.expression, resultType: f.computed.resultType } } : {}),
|
|
34
34
|
// present only for translated fields, so schemas without translations hash as before
|
|
35
35
|
...(f.localized === true ? { localized: true } : {}),
|
|
36
|
+
// present only for components fields (§45.4)
|
|
37
|
+
...(f.type === "components" && f.components
|
|
38
|
+
? { components: { allowed: [...f.components.allowed], min: f.components.min ?? null, max: f.components.max ?? null } }
|
|
39
|
+
: {}),
|
|
36
40
|
})),
|
|
37
41
|
}));
|
|
42
|
+
// the component types (§45.4) join the projection only when the project
|
|
43
|
+
// has some: the generated types name their sub-fields; a schema without
|
|
44
|
+
// them hashes as before
|
|
45
|
+
const projection = schema.componentTypes && schema.componentTypes.length > 0
|
|
46
|
+
? {
|
|
47
|
+
collections,
|
|
48
|
+
componentTypes: schema.componentTypes.map((t) => ({
|
|
49
|
+
key: t.key,
|
|
50
|
+
displayName: t.displayName,
|
|
51
|
+
fields: t.fields.map((s) => ({
|
|
52
|
+
key: s.key,
|
|
53
|
+
displayName: s.displayName,
|
|
54
|
+
type: s.type,
|
|
55
|
+
required: s.required,
|
|
56
|
+
options: s.options ?? null,
|
|
57
|
+
sensitive: s.sensitive === true,
|
|
58
|
+
})),
|
|
59
|
+
})),
|
|
60
|
+
}
|
|
61
|
+
: collections;
|
|
38
62
|
return createHash("sha256").update(JSON.stringify(projection)).digest("hex").slice(0, 12);
|
|
39
63
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Schema, type SchemaCollection } from "./schema.js";
|
|
1
|
+
import { type Schema, type SchemaCollection, type SchemaComponentType } from "./schema.js";
|
|
2
2
|
/** TypeScript types for the content a site reads through @mapled/next:
|
|
3
3
|
one interface per collection and single, plus keyed maps. Sensitive
|
|
4
4
|
fields never reach the site, so they are left out. */
|
|
@@ -16,9 +16,25 @@ export declare function singular(word: string): string;
|
|
|
16
16
|
export declare function pascal(words: string[]): string;
|
|
17
17
|
/** `blog-posts` → `BlogPost`; singles keep their name (`homepage` → `Homepage`). */
|
|
18
18
|
export declare function typeName(key: string, kind: "collection" | "single"): string;
|
|
19
|
+
/** The names the file declares itself — no collection or component type
|
|
20
|
+
may take one: `mapled-schemas` is a collection like any other, and a
|
|
21
|
+
component keyed `mapled` isn't the union of them all. */
|
|
22
|
+
export declare const RESERVED_NAMES: readonly ["MapledAssetId", "MapledRecordId", "MapledJson", "MapledLocation", "MapledComponent", "MapledCollections", "MapledSingles", "MapledCollectionKey", "MapledSingleKey", "MapledSchema"];
|
|
23
|
+
/** Every name the file declares, claimed once: the reserved ones first,
|
|
24
|
+
then the collections', the component types' and the group items' —
|
|
25
|
+
a name already claimed gets a numeric suffix, whoever asks second. */
|
|
26
|
+
export declare class DeclaredNames {
|
|
27
|
+
private readonly taken;
|
|
28
|
+
claim(name: string): string;
|
|
29
|
+
}
|
|
19
30
|
/** Unique type names: the singular PascalCase of the key; the raw
|
|
20
|
-
PascalCase when two keys meet there; a numeric suffix as a last resort
|
|
21
|
-
|
|
31
|
+
PascalCase when two keys meet there; a numeric suffix as a last resort
|
|
32
|
+
— and when the name is one the file declares itself (`RESERVED_NAMES`). */
|
|
33
|
+
export declare function typeNames(collections: SchemaCollection[], declared?: DeclaredNames): Map<string, string>;
|
|
34
|
+
/** Unique names of the component types (§45.4): the PascalCase of the
|
|
35
|
+
key with `Component` after it, a numeric suffix when that is claimed
|
|
36
|
+
already — by a collection, another type or the file itself. */
|
|
37
|
+
export declare function componentNames(types: SchemaComponentType[], declared: DeclaredNames): Map<string, string>;
|
|
22
38
|
export declare function generateTypes(schema: Schema, opts?: {
|
|
23
39
|
projectName?: string;
|
|
24
40
|
sdk?: string;
|
package/dist/types.js
CHANGED
|
@@ -118,8 +118,8 @@ function renderSubField(sub) {
|
|
|
118
118
|
/** The field's line, followed by one per previous key (§33.5): a
|
|
119
119
|
renamed field is still delivered under them, typed as the field, and
|
|
120
120
|
the editor strikes them through until the site stops reading them. */
|
|
121
|
-
function renderField(field, owner, names, extras) {
|
|
122
|
-
const { required, rendered } = renderedField(field, owner, names, extras);
|
|
121
|
+
function renderField(field, owner, names, extras, components, declared) {
|
|
122
|
+
const { required, rendered } = renderedField(field, owner, names, extras, components, declared);
|
|
123
123
|
const lines = [fieldLine(field.key, field.displayName, required, rendered)];
|
|
124
124
|
for (const alias of field.aliases ?? []) {
|
|
125
125
|
lines.push(` /** @deprecated Renamed to "${comment(field.key)}" — still delivered under this key until the alias is removed in Mapled. */\n` +
|
|
@@ -127,7 +127,7 @@ function renderField(field, owner, names, extras) {
|
|
|
127
127
|
}
|
|
128
128
|
return lines.join("\n");
|
|
129
129
|
}
|
|
130
|
-
function renderedField(field, owner, names, extras) {
|
|
130
|
+
function renderedField(field, owner, names, extras, components = new Map(), declared = new DeclaredNames()) {
|
|
131
131
|
if (field.type === "relation" && field.relation) {
|
|
132
132
|
const target = names.get(field.relation.target) ?? field.relation.target;
|
|
133
133
|
const many = field.relation.cardinality === "many";
|
|
@@ -137,7 +137,8 @@ function renderedField(field, owner, names, extras) {
|
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
if (field.type === "group" && field.group) {
|
|
140
|
-
|
|
140
|
+
// the group's interface is a declared name like any other: a component or a collection may have taken it
|
|
141
|
+
const itemName = declared.claim(`${owner}${pascal(wordsOf(field.key))}${field.group.repeatable ? "Item" : ""}`);
|
|
141
142
|
const kept = field.group.fields.filter((s) => s.sensitive !== true);
|
|
142
143
|
const hidden = field.group.fields.filter((s) => s.sensitive === true).map((s) => s.key);
|
|
143
144
|
const lines = kept.map(renderSubField);
|
|
@@ -153,38 +154,95 @@ function renderedField(field, owner, names, extras) {
|
|
|
153
154
|
if (field.type === "computed" && field.computed) {
|
|
154
155
|
return { required: false, rendered: computedType(field.computed.resultType) };
|
|
155
156
|
}
|
|
157
|
+
// components (§45.4): a list of the types the field allows, told apart by `_type`
|
|
158
|
+
if (field.type === "components" && field.components) {
|
|
159
|
+
const allowed = field.components.allowed.map((k) => components.get(k)).filter((n) => n !== undefined);
|
|
160
|
+
const union = allowed.length === 0 ? "never" : allowed.length === 1 ? allowed[0] : `(${allowed.join(" | ")})`;
|
|
161
|
+
const limits = [
|
|
162
|
+
...(field.components.min ? [`at least ${field.components.min}`] : []),
|
|
163
|
+
...(field.components.max ? [`up to ${field.components.max}`] : []),
|
|
164
|
+
];
|
|
165
|
+
const note = `components${limits.length > 0 ? `, ${limits.join(", ")} items` : ""}${field.localized === true ? ", translated" : ""}`;
|
|
166
|
+
return { required: field.required, rendered: { type: `${union}[]`, note } };
|
|
167
|
+
}
|
|
156
168
|
const scalar = scalarType(field.type, field.options);
|
|
157
169
|
// translated (§28.2): one language per read — `locale` on the read picks it
|
|
158
170
|
return { required: field.required, rendered: field.localized === true ? { ...scalar, note: `${scalar.note}, translated` } : scalar };
|
|
159
171
|
}
|
|
172
|
+
/** The names the file declares itself — no collection or component type
|
|
173
|
+
may take one: `mapled-schemas` is a collection like any other, and a
|
|
174
|
+
component keyed `mapled` isn't the union of them all. */
|
|
175
|
+
export const RESERVED_NAMES = [
|
|
176
|
+
"MapledAssetId",
|
|
177
|
+
"MapledRecordId",
|
|
178
|
+
"MapledJson",
|
|
179
|
+
"MapledLocation",
|
|
180
|
+
"MapledComponent",
|
|
181
|
+
"MapledCollections",
|
|
182
|
+
"MapledSingles",
|
|
183
|
+
"MapledCollectionKey",
|
|
184
|
+
"MapledSingleKey",
|
|
185
|
+
"MapledSchema",
|
|
186
|
+
];
|
|
187
|
+
/** Every name the file declares, claimed once: the reserved ones first,
|
|
188
|
+
then the collections', the component types' and the group items' —
|
|
189
|
+
a name already claimed gets a numeric suffix, whoever asks second. */
|
|
190
|
+
export class DeclaredNames {
|
|
191
|
+
taken = new Set(RESERVED_NAMES);
|
|
192
|
+
claim(name) {
|
|
193
|
+
let unique = name;
|
|
194
|
+
for (let n = 2; this.taken.has(unique); n++)
|
|
195
|
+
unique = `${name}${n}`;
|
|
196
|
+
this.taken.add(unique);
|
|
197
|
+
return unique;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
160
200
|
/** Unique type names: the singular PascalCase of the key; the raw
|
|
161
|
-
PascalCase when two keys meet there; a numeric suffix as a last resort
|
|
162
|
-
|
|
201
|
+
PascalCase when two keys meet there; a numeric suffix as a last resort
|
|
202
|
+
— and when the name is one the file declares itself (`RESERVED_NAMES`). */
|
|
203
|
+
export function typeNames(collections, declared = new DeclaredNames()) {
|
|
163
204
|
const preferred = collections.map((c) => typeName(c.key, c.kind));
|
|
164
205
|
const counts = new Map();
|
|
165
206
|
for (const name of preferred)
|
|
166
207
|
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
167
|
-
const taken = new Set();
|
|
168
208
|
const out = new Map();
|
|
169
209
|
collections.forEach((c, i) => {
|
|
170
210
|
let name = preferred[i];
|
|
171
211
|
if ((counts.get(name) ?? 0) > 1)
|
|
172
212
|
name = pascal(wordsOf(c.key));
|
|
173
|
-
|
|
174
|
-
for (let n = 2; taken.has(unique); n++)
|
|
175
|
-
unique = `${name}${n}`;
|
|
176
|
-
taken.add(unique);
|
|
177
|
-
out.set(c.key, unique);
|
|
213
|
+
out.set(c.key, declared.claim(name));
|
|
178
214
|
});
|
|
179
215
|
return out;
|
|
180
216
|
}
|
|
217
|
+
/** Unique names of the component types (§45.4): the PascalCase of the
|
|
218
|
+
key with `Component` after it, a numeric suffix when that is claimed
|
|
219
|
+
already — by a collection, another type or the file itself. */
|
|
220
|
+
export function componentNames(types, declared) {
|
|
221
|
+
const out = new Map();
|
|
222
|
+
for (const t of types)
|
|
223
|
+
out.set(t.key, declared.claim(`${pascal(wordsOf(t.key))}Component`));
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
/** One interface per component type: `_type` as its key, `_key` the
|
|
227
|
+
item's own id, then its sub-fields. */
|
|
228
|
+
function renderComponent(type, name) {
|
|
229
|
+
const lines = [
|
|
230
|
+
` /** The component's type */\n _type: ${JSON.stringify(type.key)};`,
|
|
231
|
+
" /** The item's own id — stays with it across saves */\n _key: string;",
|
|
232
|
+
...type.fields.map(renderSubField),
|
|
233
|
+
];
|
|
234
|
+
return `/** ${comment(type.displayName)} — a component (key "${comment(type.key)}") */\nexport interface ${name} {\n${lines.join("\n")}\n}`;
|
|
235
|
+
}
|
|
181
236
|
export function generateTypes(schema, opts = {}) {
|
|
182
237
|
// the package the site reads through: "@mapled/next" unless the framework says otherwise
|
|
183
238
|
const sdk = opts.sdk ?? "@mapled/next";
|
|
184
239
|
// a React app's assetUrl comes from the client its hooks are built on; the HTML runtime has none to call
|
|
185
240
|
const assets = sdk === "@mapled/react" || sdk === "@mapled/vanilla" ? "@mapled/client" : sdk;
|
|
186
241
|
const hash = schemaHash(schema);
|
|
187
|
-
const
|
|
242
|
+
const declared = new DeclaredNames();
|
|
243
|
+
const names = typeNames(schema.collections, declared);
|
|
244
|
+
const componentTypes = schema.componentTypes ?? [];
|
|
245
|
+
const components = componentNames(componentTypes, declared);
|
|
188
246
|
const blocks = [];
|
|
189
247
|
blocks.push([
|
|
190
248
|
`// Types of the published content this site reads through ${sdk}.`,
|
|
@@ -198,12 +256,19 @@ export function generateTypes(schema, opts = {}) {
|
|
|
198
256
|
"export type MapledJson = Record<string, unknown> | unknown[];",
|
|
199
257
|
"export type MapledLocation = { lat: number; lng: number };",
|
|
200
258
|
].join("\n"));
|
|
259
|
+
// the component types come first: a components field names them
|
|
260
|
+
for (const t of componentTypes)
|
|
261
|
+
blocks.push(renderComponent(t, components.get(t.key)));
|
|
262
|
+
if (componentTypes.length > 0) {
|
|
263
|
+
blocks.push("/** Any component of the project — switch on `_type` to render an item of a components field. */\n" +
|
|
264
|
+
`export type MapledComponent = ${componentTypes.map((t) => components.get(t.key)).join(" | ")};`);
|
|
265
|
+
}
|
|
201
266
|
for (const c of schema.collections) {
|
|
202
267
|
const name = names.get(c.key);
|
|
203
268
|
const extras = [];
|
|
204
269
|
const kept = c.fields.filter((f) => !f.sensitive);
|
|
205
270
|
const hidden = c.fields.filter((f) => f.sensitive).map((f) => f.key);
|
|
206
|
-
const lines = kept.map((f) => renderField(f, name, names, extras));
|
|
271
|
+
const lines = kept.map((f) => renderField(f, name, names, extras, components, declared));
|
|
207
272
|
if (hidden.length > 0)
|
|
208
273
|
lines.push(` // Sensitive, never delivered: ${comment(hidden.join(", "))}`);
|
|
209
274
|
const traits = [
|
package/dist/vanillaDigests.js
CHANGED
|
@@ -11,4 +11,6 @@ export const VANILLA_DIGESTS = {
|
|
|
11
11
|
"0.1.2": "sha384-bhIJoFuEGouAZtHbYeNZeN0m6fqFVWpQw+YkSMJhfk/7m5bUwS/qVWZ3xczw/1du",
|
|
12
12
|
"0.2.0": "sha384-HJ5XG9AhAlbKe6jPRVaef3whiVup/5WhprMwqC6RwWhpq6Pb0OsRLzbLmLJ8WH0f",
|
|
13
13
|
"0.2.1": "sha384-6apTHYxXrozzwFCqNU+70Z9pa/FDnhArNomcKQNWbeKRX4scjFiOc9d7wWKCOvTU",
|
|
14
|
+
"0.2.2": "sha384-lfhwQoPUtrbLcD2HXSLr2o1z+aUAjotB62i3LFMGxzBlK5lfCQPQPLOoWL+1DBMc",
|
|
15
|
+
"0.2.3": "sha384-SuKH/yk2hpNI+Ec2QGKpZ7jVxe+0Ep0x/fchL560g+XuzG5hnVCIhG9OKX6BKdpY",
|
|
14
16
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapled/cli",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.4",
|
|
4
4
|
"description": "Mapled CLI — sign in, link a project, generate TypeScript types for its content, create forms, preview drafts and check the site's integration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://mapled.io",
|