@mapled/cli 0.14.3 → 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 CHANGED
@@ -50,7 +50,7 @@ Only public values live here — the project id, where the generated types go, t
50
50
  npx @mapled/cli types generate
51
51
  ```
52
52
 
53
- Reads the project's schema and writes `mapled-types.ts` (the path from `mapled.json`, or `--out <file>`): one interface per collection and single, keyed maps, a `MapledSchema` that ties them together, and doc comments with the field types. Sensitive fields never reach the site and are left out.
53
+ Reads the project's schema and writes `mapled-types.ts` (the path from `mapled.json`, or `--out <file>`): one interface per collection and single, keyed maps, a `MapledSchema` that ties them together, and doc comments with the field types. Sensitive fields never reach the site and are left out. A group field is typed by an interface of its items; the project's component types become one interface each (`HeroComponent`, with `_type: "hero"` and the item's `_key`), a `components` field a list of the union of the types it allows, and `MapledComponent` the union of them all — switch on `_type` to render an item.
54
54
 
55
55
  ```ts
56
56
  import type { MapledSchema } from "./mapled-types";
package/dist/commands.js CHANGED
@@ -179,7 +179,7 @@ export async function schemaPull(ctx, flags) {
179
179
  ctx.out(`${SCHEMA_FILE} is up to date (schema ${pin.hash}).`);
180
180
  return;
181
181
  }
182
- const changes = diffSchema(existing.pin.collections, pin.collections);
182
+ const changes = diffSchema(existing.pin.collections, pin.collections, { before: existing.pin.componentTypes, after: pin.componentTypes });
183
183
  await writePin(found.dir, pin);
184
184
  ctx.out(`Updated ${SCHEMA_FILE} (schema ${existing.pin.hash} → ${pin.hash}): ${summarizeChanges(changes).toLowerCase()}.`);
185
185
  ctx.out("");
@@ -199,7 +199,7 @@ export async function schemaDiff(ctx, flags) {
199
199
  throw new CliError(`No ${SCHEMA_FILE} yet. Run \`mapled schema pull\` to record the schema this site is built against.`);
200
200
  }
201
201
  const live = pinSchema(await session.get("/v1/agent/schema"), found.config.project);
202
- const changes = diffSchema(existing.pin.collections, live.collections);
202
+ const changes = diffSchema(existing.pin.collections, live.collections, { before: existing.pin.componentTypes, after: live.componentTypes });
203
203
  if (flags.json) {
204
204
  ctx.out(JSON.stringify({ from: existing.pin.hash, to: live.hash, changes }, null, 2));
205
205
  }
@@ -732,8 +732,8 @@ export async function doctor(ctx, flags) {
732
732
  checks.push(dr.checkCli(ctx.version, status?.sdk["@mapled/cli"]));
733
733
  // the live facts first: the revalidate route's answer matters once a page listens (#435)
734
734
  const live = !framework || framework === "nextjs" ? await findLive(dir) : null;
735
- checks.push(dr.checkRoute("revalidate", await dr.findRoute(dir, "revalidate"), framework, (live?.listeners.length ?? 0) > 0));
736
- checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview"), framework));
735
+ checks.push(dr.checkRoute("revalidate", await dr.findRoute(dir, "revalidate", framework), framework, (live?.listeners.length ?? 0) > 0));
736
+ checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview", framework), framework));
737
737
  // the code is read only where the check applies: a Next.js site (or one doctor can't place)
738
738
  checks.push(dr.checkLive(live, framework));
739
739
  if (status) {
package/dist/doctor.d.ts CHANGED
@@ -101,6 +101,12 @@ export declare function installedVersion(dir: string, pkg: string): Promise<Pack
101
101
  export declare const rendersInBrowser: (framework: string | null | undefined) => boolean;
102
102
  /** The first-party package a site of this framework reads through. */
103
103
  export declare const sdkOf: (framework: string | null | undefined) => string;
104
+ /** The frameworks whose revalidate and preview routes doctor reads: Next.js
105
+ (`app/api/mapled/…/route.ts`, `pages/api/mapled/…`), Astro
106
+ (`src/pages/api/mapled/….ts` — the same paths ROUTE_FILES already names
107
+ for a Pages Router site under `src/`) and Nuxt (`server/api/mapled/…`,
108
+ or the module in `nuxt.config.ts`, which mounts them — `moduleRoute`). */
109
+ export declare const checksRoutesOf: (framework: string | null | undefined) => boolean;
104
110
  /** How a plain HTML site loads "@mapled/vanilla": a script tag in its
105
111
  pages — from a CDN, where the version is in the address, or a copy of
106
112
  the single file next to the pages, where it is in the file's first
@@ -152,6 +158,20 @@ export declare function detectFramework(dir: string): Promise<string | undefined
152
158
  /** -1, 0 or 1 comparing dotted numeric versions; anything else compares by number. */
153
159
  export declare function compareVersions(a: string, b: string): number;
154
160
  export type RouteKind = "revalidate" | "preview";
161
+ /** The Nuxt module mounts every route itself: a site whose `nuxt.config`
162
+ lists "@mapled/nuxt/module" has them without a file. What `findRoute`
163
+ answers for such a site, when no route file of its own is found. */
164
+ export declare const NUXT_CONFIG_FILES: string[];
165
+ export declare const NUXT_MODULE = "@mapled/nuxt/module";
166
+ export declare function moduleRoute(dir: string): Promise<RouteFile | null>;
167
+ /** Whether a nuxt.config's text lists the module, routes on, in its
168
+ `modules` array: as the string "@mapled/nuxt/module" or as the
169
+ identifier a default import of it binds, bare or as the first element
170
+ of a tuple whose options don't say `routes: false`. Comments don't
171
+ count; neither does an import that never reaches `modules`.
172
+ Conservative: a config that can't be read this way claims nothing,
173
+ and doctor asks for the route files. */
174
+ export declare function mountsModuleRoutes(text: string): boolean;
155
175
  export declare const ROUTE_HANDLER: Record<RouteKind, string>;
156
176
  /** `cacheMark` and `fetchCache` — the revalidate route only (revalidateRouteFacts). */
157
177
  export type RouteFile = {
@@ -160,7 +180,7 @@ export type RouteFile = {
160
180
  cacheMark?: boolean;
161
181
  fetchCache?: boolean;
162
182
  };
163
- export declare function findRoute(dir: string, kind: RouteKind): Promise<RouteFile | null>;
183
+ export declare function findRoute(dir: string, kind: RouteKind, framework?: string | null): Promise<RouteFile | null>;
164
184
  /** GET the site's preview route without a token — the adapter answers 400. */
165
185
  export declare function probePreview(origin: string, previewPath: string, f: Fetch): Promise<{
166
186
  status: number | null;
package/dist/doctor.js CHANGED
@@ -113,7 +113,19 @@ export async function installedVersion(dir, pkg) {
113
113
  keys are the ones Mapled knows by (lib/siteStack.ts of the API). */
114
114
  export const rendersInBrowser = (framework) => framework === "react-spa" || framework === "plain-html";
115
115
  /** The first-party package a site of this framework reads through. */
116
- export const sdkOf = (framework) => !framework || framework === "nextjs" ? "@mapled/next" : framework === "react-spa" ? "@mapled/react" : framework === "plain-html" ? "@mapled/vanilla" : "@mapled/client";
116
+ export const sdkOf = (framework) => !framework || framework === "nextjs" ? "@mapled/next" : framework === "react-spa" ? "@mapled/react" : framework === "plain-html" ? "@mapled/vanilla" : framework === "astro" ? "@mapled/astro" : framework === "nuxt" ? "@mapled/nuxt" : "@mapled/client";
117
+ /** The frameworks whose revalidate and preview routes doctor reads: Next.js
118
+ (`app/api/mapled/…/route.ts`, `pages/api/mapled/…`), Astro
119
+ (`src/pages/api/mapled/….ts` — the same paths ROUTE_FILES already names
120
+ for a Pages Router site under `src/`) and Nuxt (`server/api/mapled/…`,
121
+ or the module in `nuxt.config.ts`, which mounts them — `moduleRoute`). */
122
+ export const checksRoutesOf = (framework) => !framework || framework === "nextjs" || framework === "astro" || framework === "nuxt";
123
+ /** where the adapter's handlers come from, and where the route file goes, by framework */
124
+ const adapterOf = (framework, kind) => framework === "astro"
125
+ ? { server: '"@mapled/astro/server"', file: `src/pages/api/mapled/${kind}.ts` }
126
+ : framework === "nuxt"
127
+ ? { server: '"@mapled/nuxt/server"', file: `server/api/mapled/${kind}.${kind === "revalidate" ? "post" : "get"}.ts` }
128
+ : { server: '"@mapled/next/server"', file: `app/api/mapled/${kind}/route.ts` };
117
129
  const SKIPPED_DIRS = new Set(["node_modules", ".git", ".next", ".vercel", ".netlify", "vendor"]);
118
130
  export async function findScriptTag(dir, site) {
119
131
  // pages first, breadth first, and not forever: a site's pages sit near its root
@@ -444,20 +456,116 @@ const ROUTE_FILES = {
444
456
  "src/app/api/mapled/revalidate/route",
445
457
  "pages/api/mapled/revalidate",
446
458
  "src/pages/api/mapled/revalidate",
459
+ // Nuxt: a Nitro route, with or without the method in its name
460
+ "server/api/mapled/revalidate.post",
461
+ "server/api/mapled/revalidate",
447
462
  ],
448
463
  preview: [
449
464
  "app/api/mapled/preview/route",
450
465
  "src/app/api/mapled/preview/route",
451
466
  "pages/api/mapled/preview",
452
467
  "src/pages/api/mapled/preview",
468
+ "server/api/mapled/preview.get",
469
+ "server/api/mapled/preview",
453
470
  ],
454
471
  };
472
+ /** The Nuxt module mounts every route itself: a site whose `nuxt.config`
473
+ lists "@mapled/nuxt/module" has them without a file. What `findRoute`
474
+ answers for such a site, when no route file of its own is found. */
475
+ export const NUXT_CONFIG_FILES = ["nuxt.config.ts", "nuxt.config.mts", "nuxt.config.js", "nuxt.config.mjs"];
476
+ export const NUXT_MODULE = "@mapled/nuxt/module";
477
+ export async function moduleRoute(dir) {
478
+ for (const file of NUXT_CONFIG_FILES) {
479
+ let text;
480
+ try {
481
+ text = await readFile(path.join(dir, file), "utf8");
482
+ }
483
+ catch {
484
+ continue;
485
+ }
486
+ return mountsModuleRoutes(text) ? { file: `${file} (${NUXT_MODULE} mounts it)`, usesAdapter: true, cacheMark: true } : null;
487
+ }
488
+ return null;
489
+ }
490
+ /** Whether a nuxt.config's text lists the module, routes on, in its
491
+ `modules` array: as the string "@mapled/nuxt/module" or as the
492
+ identifier a default import of it binds, bare or as the first element
493
+ of a tuple whose options don't say `routes: false`. Comments don't
494
+ count; neither does an import that never reaches `modules`.
495
+ Conservative: a config that can't be read this way claims nothing,
496
+ and doctor asks for the route files. */
497
+ export function mountsModuleRoutes(text) {
498
+ // comments go: block comments, and line comments that aren't the `//` of a URL
499
+ const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
500
+ // the names a default import of the module binds
501
+ const names = [...code.matchAll(/import\s+([A-Za-z_$][\w$]*)\s*(?:,\s*\{[^}]*\})?\s*from\s*["'`]@mapled\/nuxt\/module["'`]/g)].map((m) => m[1]);
502
+ const open = code.search(/\bmodules\s*:\s*\[/);
503
+ if (open < 0)
504
+ return false;
505
+ const array = balanced(code, code.indexOf("[", open));
506
+ if (array === null)
507
+ return false;
508
+ // each element of the array, at depth one
509
+ for (const element of elementsOf(array)) {
510
+ const asString = /^\s*["'`]@mapled\/nuxt\/module["'`]\s*$/.test(element);
511
+ const asName = names.some((n) => new RegExp(`^\\s*${n.replace(/\$/g, "\\$")}\\s*$`).test(element));
512
+ if (asString || asName)
513
+ return true;
514
+ const tuple = element.trim().startsWith("[") ? balanced(element, element.indexOf("[")) : null;
515
+ if (tuple === null)
516
+ continue;
517
+ const [first, ...options] = elementsOf(tuple);
518
+ const firstIs = first !== undefined && (/^\s*["'`]@mapled\/nuxt\/module["'`]\s*$/.test(first) || names.some((n) => first.trim() === n));
519
+ if (!firstIs)
520
+ continue;
521
+ return !/\broutes\s*:\s*false\b/.test(options.join(","));
522
+ }
523
+ return false;
524
+ }
525
+ /** the text inside the bracket at `at` and its match, or null when unbalanced */
526
+ function balanced(code, at) {
527
+ const pairs = { "[": "]", "{": "}", "(": ")" };
528
+ const stack = [];
529
+ for (let i = at; i < code.length; i++) {
530
+ const c = code[i];
531
+ if (c in pairs)
532
+ stack.push(pairs[c]);
533
+ else if (c === "]" || c === "}" || c === ")") {
534
+ if (stack.pop() !== c)
535
+ return null;
536
+ if (stack.length === 0)
537
+ return code.slice(at + 1, i);
538
+ }
539
+ }
540
+ return null;
541
+ }
542
+ /** the comma-separated elements of a bracket's inside, at its own depth */
543
+ function elementsOf(inside) {
544
+ const out = [];
545
+ let depth = 0;
546
+ let from = 0;
547
+ for (let i = 0; i < inside.length; i++) {
548
+ const c = inside[i];
549
+ if (c === "[" || c === "{" || c === "(")
550
+ depth += 1;
551
+ else if (c === "]" || c === "}" || c === ")")
552
+ depth -= 1;
553
+ else if (c === "," && depth === 0) {
554
+ out.push(inside.slice(from, i));
555
+ from = i + 1;
556
+ }
557
+ }
558
+ const last = inside.slice(from);
559
+ if (last.trim())
560
+ out.push(last);
561
+ return out;
562
+ }
455
563
  const ROUTE_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".jsx"];
456
564
  export const ROUTE_HANDLER = {
457
565
  revalidate: "createRevalidateHandler",
458
566
  preview: "createPreviewHandler",
459
567
  };
460
- export async function findRoute(dir, kind) {
568
+ export async function findRoute(dir, kind, framework) {
461
569
  for (const base of ROUTE_FILES[kind]) {
462
570
  for (const ext of ROUTE_EXTENSIONS) {
463
571
  const rel = base + ext;
@@ -472,7 +580,7 @@ export async function findRoute(dir, kind) {
472
580
  return kind === "revalidate" ? { file: rel, usesAdapter, ...revalidateRouteFacts(text) } : { file: rel, usesAdapter };
473
581
  }
474
582
  }
475
- return null;
583
+ return framework === "nuxt" ? moduleRoute(dir) : null;
476
584
  }
477
585
  /** GET the site's preview route without a token — the adapter answers 400. */
478
586
  export async function probePreview(origin, previewPath, f) {
@@ -656,22 +764,23 @@ export function checkRoute(kind, found, framework, listens = false) {
656
764
  : "Not needed: the client trades Mapled's preview link on the page itself.",
657
765
  };
658
766
  }
659
- if (framework && framework !== "nextjs") {
767
+ if (!checksRoutesOf(framework)) {
660
768
  return { key, label, status: "skipped", detail: `No ${kind} route check for ${framework} sites yet.` };
661
769
  }
770
+ const adapter = adapterOf(framework, kind);
662
771
  if (!found) {
663
772
  return kind === "revalidate"
664
773
  ? {
665
774
  key,
666
775
  label,
667
776
  status: "failed",
668
- detail: `app/api/mapled/revalidate/route.ts is missing — mount ${handler} from "@mapled/next/server".`,
777
+ detail: `${adapter.file} is missing — mount ${handler} from ${adapter.server}.`,
669
778
  }
670
779
  : {
671
780
  key,
672
781
  label,
673
782
  status: "warning",
674
- detail: `app/api/mapled/preview/route.ts is missing — mount ${handler} from "@mapled/next/server" so Preview from Mapled works.`,
783
+ detail: `${adapter.file} is missing — mount ${handler} from ${adapter.server} so Preview from Mapled works.`,
675
784
  };
676
785
  }
677
786
  if (kind === "revalidate" && listens && !found.usesAdapter && !found.cacheMark) {
@@ -679,7 +788,7 @@ export function checkRoute(kind, found, framework, listens = false) {
679
788
  key,
680
789
  label,
681
790
  status: "warning",
682
- detail: `${found.file} doesn't use ${handler} and doesn't answer with cacheMark() — the release route then names no release, so open tabs don't follow Publish. Use ${handler} from "@mapled/next/server", or answer with the header x-mapled-cache: await cacheMark().`,
791
+ detail: `${found.file} doesn't use ${handler} and doesn't answer with cacheMark() — the release route then names no release, so open tabs don't follow Publish. Use ${handler} from ${adapter.server}, or answer with the header x-mapled-cache: await cacheMark().`,
683
792
  };
684
793
  }
685
794
  if (kind === "revalidate" && listens && found.fetchCache) {
@@ -691,7 +800,7 @@ export function checkRoute(kind, found, framework, listens = false) {
691
800
  };
692
801
  }
693
802
  if (!found.usesAdapter) {
694
- return { key, label, status: "warning", detail: `${found.file} exists but doesn't use ${handler} from "@mapled/next/server".` };
803
+ return { key, label, status: "warning", detail: `${found.file} exists but doesn't use ${handler} from ${adapter.server}.` };
695
804
  }
696
805
  return { key, label, status: "passed", detail: found.file };
697
806
  }
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/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 = [
@@ -12,4 +12,5 @@ export const VANILLA_DIGESTS = {
12
12
  "0.2.0": "sha384-HJ5XG9AhAlbKe6jPRVaef3whiVup/5WhprMwqC6RwWhpq6Pb0OsRLzbLmLJ8WH0f",
13
13
  "0.2.1": "sha384-6apTHYxXrozzwFCqNU+70Z9pa/FDnhArNomcKQNWbeKRX4scjFiOc9d7wWKCOvTU",
14
14
  "0.2.2": "sha384-lfhwQoPUtrbLcD2HXSLr2o1z+aUAjotB62i3LFMGxzBlK5lfCQPQPLOoWL+1DBMc",
15
+ "0.2.3": "sha384-SuKH/yk2hpNI+Ec2QGKpZ7jVxe+0Ep0x/fchL560g+XuzG5hnVCIhG9OKX6BKdpY",
15
16
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/cli",
3
- "version": "0.14.3",
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",