@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/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
- scope: "collection" | "field" | "subfield";
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[]): SchemaChange[];
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
- return { project, hash: schemaHash({ collections }), collections };
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
- return pinSchema({ collections: collections }, obj.project);
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
@@ -114,6 +114,7 @@ export type PreviewAnswer = {
114
114
  url: string;
115
115
  expiresAt?: string;
116
116
  path: string | null;
117
+ locale?: string;
117
118
  } | {
118
119
  status: "not_deployed";
119
120
  origin: string;
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
- return { manifest, added, changed, unchanged, kept, dropped, capabilitiesChanged };
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 projection = schema.collections.map((c) => ({
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
- export declare function typeNames(collections: SchemaCollection[]): Map<string, string>;
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
- const itemName = `${owner}${pascal(wordsOf(field.key))}${field.group.repeatable ? "Item" : ""}`;
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
- export function typeNames(collections) {
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
- let unique = name;
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 names = typeNames(schema.collections);
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 = [
@@ -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.2",
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",