cronus-ui 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/dist/commands/add-page.d.ts +108 -0
  4. package/dist/commands/add-page.js +642 -0
  5. package/dist/commands/add.d.ts +9 -0
  6. package/dist/commands/add.js +114 -0
  7. package/dist/commands/ai.d.ts +14 -0
  8. package/dist/commands/ai.js +69 -0
  9. package/dist/commands/compose.d.ts +82 -0
  10. package/dist/commands/compose.js +403 -0
  11. package/dist/commands/diff.d.ts +8 -0
  12. package/dist/commands/diff.js +55 -0
  13. package/dist/commands/init.d.ts +9 -0
  14. package/dist/commands/init.js +53 -0
  15. package/dist/commands/list.d.ts +7 -0
  16. package/dist/commands/list.js +28 -0
  17. package/dist/commands/theme.d.ts +23 -0
  18. package/dist/commands/theme.js +735 -0
  19. package/dist/commands/upgrade.d.ts +51 -0
  20. package/dist/commands/upgrade.js +840 -0
  21. package/dist/compose/data-slots.d.ts +71 -0
  22. package/dist/compose/data-slots.js +104 -0
  23. package/dist/compose/manifest.d.ts +90 -0
  24. package/dist/compose/manifest.js +224 -0
  25. package/dist/compose/plan.d.ts +164 -0
  26. package/dist/compose/plan.js +506 -0
  27. package/dist/compose/preview.d.ts +10 -0
  28. package/dist/compose/preview.js +48 -0
  29. package/dist/compose/reload.d.ts +56 -0
  30. package/dist/compose/reload.js +138 -0
  31. package/dist/compose/render.d.ts +123 -0
  32. package/dist/compose/render.js +404 -0
  33. package/dist/compose/templates.d.ts +22 -0
  34. package/dist/compose/templates.js +76 -0
  35. package/dist/compose.d.ts +10 -0
  36. package/dist/compose.js +8 -0
  37. package/dist/config.d.ts +94 -0
  38. package/dist/config.js +38 -0
  39. package/dist/index.d.ts +3 -0
  40. package/dist/index.js +184 -0
  41. package/dist/registry.d.ts +59 -0
  42. package/dist/registry.js +96 -0
  43. package/dist/utils.d.ts +72 -0
  44. package/dist/utils.js +186 -0
  45. package/package.json +68 -0
  46. package/templates/apps/chat.json +44 -0
  47. package/templates/apps/finance.json +44 -0
  48. package/templates/apps/landing-agency.json +31 -0
  49. package/templates/apps/landing-agents.json +32 -0
  50. package/templates/apps/landing-broadcast.json +29 -0
  51. package/templates/apps/landing-care.json +28 -0
  52. package/templates/apps/landing-coverage.json +23 -0
  53. package/templates/apps/landing-docs.json +29 -0
  54. package/templates/apps/landing-glass.json +28 -0
  55. package/templates/apps/landing-ops.json +29 -0
  56. package/templates/apps/landing-premium.json +31 -0
  57. package/templates/apps/landing-secure.json +31 -0
  58. package/templates/apps/landing-shop.json +27 -0
  59. package/templates/apps/landing-studio.json +30 -0
  60. package/templates/apps/landing.json +23 -0
  61. package/templates/apps/mail.json +44 -0
  62. package/templates/apps/saas.json +64 -0
  63. package/templates/apps/store.json +75 -0
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Pure plan builder + validator. Turns a parsed {@link AppManifest} + caller
3
+ * `choices` into a {@link ComposePlan} the renderer consumes, and aggregates ALL
4
+ * semantic errors (block exists? kind fits slot? routes unique/valid? no
5
+ * dynamic-slug collisions Next rejects? chrome refs exist? extras resolve to a
6
+ * page-kind special file? deps safe?) before returning — never bailing on the
7
+ * first. Nothing here touches the filesystem; the command feeds it the registry
8
+ * index, meta, and shipped chrome sources.
9
+ */
10
+ import { assertValidDependency, closestName } from "../utils.js";
11
+ import { blockRefParts } from "./manifest.js";
12
+ /** Thrown by {@link buildComposePlan} carrying EVERY semantic error found. */
13
+ export class ComposePlanError extends Error {
14
+ errors;
15
+ constructor(errors) {
16
+ super(`Invalid compose plan:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
17
+ this.name = "ComposePlanError";
18
+ this.errors = errors;
19
+ }
20
+ }
21
+ /** Valid App Router route: "/", or "/seg" parts of `[a-z0-9-]` / dynamic `[x]`/`[...x]`. */
22
+ const ROUTE_SEGMENT_RE = /^(?:\[(?:\.\.\.)?[a-z0-9-]+\]|[a-z0-9-]+)$/;
23
+ function isValidRoute(route) {
24
+ if (route === "/")
25
+ return true;
26
+ if (!route.startsWith("/"))
27
+ return false;
28
+ const segments = route.slice(1).split("/");
29
+ if (segments.some((s) => s.length === 0))
30
+ return false; // trailing/double slash
31
+ return segments.every((s) => ROUTE_SEGMENT_RE.test(s));
32
+ }
33
+ /** True for a dynamic segment `[x]` / `[...x]`. */
34
+ function isDynamicSegment(seg) {
35
+ return seg.startsWith("[") && seg.endsWith("]");
36
+ }
37
+ /** The slug NAME inside a dynamic segment (`[...id]` → "id", `[id]` → "id"). */
38
+ function dynamicSlugName(seg) {
39
+ return seg.slice(1, -1).replace(/^\.\.\./, "");
40
+ }
41
+ /**
42
+ * The static route SKELETON: every dynamic segment collapsed to a positional
43
+ * marker so `/products/[id]` and `/products/[slug]` share a skeleton (Next treats
44
+ * them as the same path shape and refuses two different slug names for it).
45
+ */
46
+ function routeSkeleton(route) {
47
+ if (route === "/")
48
+ return "/";
49
+ return route
50
+ .slice(1)
51
+ .split("/")
52
+ .map((seg) => (isDynamicSegment(seg) ? "[*]" : seg))
53
+ .join("/");
54
+ }
55
+ /**
56
+ * The Next special-file names an `extras` block may target in F1. Each maps its
57
+ * extras KEY to the emitted `app/<file>` special-file path. Only page-kind blocks
58
+ * may fill these (they are full-page surfaces).
59
+ */
60
+ const EXTRAS_SPECIAL_FILES = {
61
+ "not-found": "app/not-found.tsx",
62
+ };
63
+ /** Resolve the brand string: explicit choice → manifest default (filled) → appName. */
64
+ function resolveBrand(manifest, input) {
65
+ const appName = input.appName ?? manifest.name;
66
+ if (input.brand !== undefined && input.brand.length > 0)
67
+ return input.brand;
68
+ const dflt = manifest.manifest.defaults?.brand;
69
+ if (dflt !== undefined && dflt.length > 0)
70
+ return dflt.replaceAll("__APP_NAME__", appName);
71
+ return appName;
72
+ }
73
+ /** Build the normalized (sorted-key) choices record persisted to `composed{}`. */
74
+ function normalizeChoices(manifest, input, planPages) {
75
+ const variants = input.variants ?? {};
76
+ const sortedVariants = {};
77
+ for (const key of Object.keys(variants).sort()) {
78
+ const v = variants[key];
79
+ if (v !== undefined)
80
+ sortedVariants[key] = v;
81
+ }
82
+ return {
83
+ variants: sortedVariants,
84
+ pages: planPages.map((p) => p.route),
85
+ brand: resolveBrand(manifest, input),
86
+ ...(input.seed !== undefined ? { seed: input.seed } : {}),
87
+ };
88
+ }
89
+ /**
90
+ * Build a {@link ComposePlan} from a parsed manifest, caller choices, the registry
91
+ * index, meta, and the shipped chrome sources. Aggregates ALL semantic errors and
92
+ * THROWS {@link ComposePlanError} when invalid. The returned plan is a pure
93
+ * function of its inputs (stable ordering, no I/O/Date).
94
+ */
95
+ export function buildComposePlan(manifest, input, index, meta, chromeSources) {
96
+ const errors = [];
97
+ const known = new Set(index.map((i) => i.name));
98
+ const blockNames = index.filter((i) => i.type === "registry:block").map((i) => i.name);
99
+ const body = manifest.manifest;
100
+ const appName = input.appName ?? manifest.name;
101
+ // Page subset (F2 `--pages`); default = all manifest pages, in order.
102
+ const wantRoutes = input.pages;
103
+ const selectedPages = wantRoutes === undefined ? body.pages : body.pages.filter((p) => wantRoutes.includes(p.route));
104
+ if (wantRoutes !== undefined) {
105
+ for (const route of wantRoutes) {
106
+ if (!body.pages.some((p) => p.route === route)) {
107
+ errors.push(`--pages: route "${route}" is not in the "${manifest.name}" template`);
108
+ }
109
+ }
110
+ if (selectedPages.length === 0) {
111
+ errors.push(`--pages: selection matched no pages in the "${manifest.name}" template`);
112
+ }
113
+ }
114
+ // Per-family variant overrides from choices (`--variant login=split`); a
115
+ // present override wins over whatever variant the manifest ref declared.
116
+ const variantOverrides = input.variants ?? {};
117
+ // Resolve a block ref against the registry index + meta, collecting errors,
118
+ // honoring variants: the effective variant is the choices override (if any),
119
+ // else the manifest ref's `{ block, variant }`. The default variant resolves to
120
+ // the bare `<slug>` item + the block's own export; a non-default variant to
121
+ // `<slug>--<id>` + the variant's export (both read from meta so a generated
122
+ // page imports exactly what ships).
123
+ const resolveBlock = (ref, where) => {
124
+ const { slug, variant: manifestVariant } = blockRefParts(ref);
125
+ if (!known.has(slug)) {
126
+ const suggestion = closestName(slug, blockNames);
127
+ errors.push(suggestion
128
+ ? `${where}: unknown block "${slug}". Did you mean "${suggestion}"?`
129
+ : `${where}: unknown block "${slug}".`);
130
+ return undefined;
131
+ }
132
+ const m = meta.blocks[slug];
133
+ if (m === undefined) {
134
+ errors.push(`${where}: block "${slug}" has no metadata (regenerate registry meta.json).`);
135
+ return undefined;
136
+ }
137
+ // Choice override wins over the manifest-declared variant.
138
+ const wantVariant = variantOverrides[slug] ?? manifestVariant;
139
+ if (wantVariant === undefined) {
140
+ // Default variant → bare item + the block's own export.
141
+ return { slug, item: slug, exportName: m.exportName, kind: m.kind };
142
+ }
143
+ const variants = m.variants ?? [];
144
+ const match = variants.find((v) => v.id === wantVariant);
145
+ if (match === undefined) {
146
+ const ids = variants.map((v) => v.id);
147
+ const suggestion = closestName(wantVariant, ids);
148
+ const hint = ids.length > 0 ? ` (known: ${ids.join(", ")})` : " (this block has no variants)";
149
+ errors.push(suggestion
150
+ ? `${where}: unknown variant "${wantVariant}" for block "${slug}". Did you mean "${suggestion}"?`
151
+ : `${where}: unknown variant "${wantVariant}" for block "${slug}"${hint}.`);
152
+ return undefined;
153
+ }
154
+ // Meta named a variant item; the registry index must actually publish it,
155
+ // else `registry.resolve(plan.blockSlugs)` would later crash with a raw
156
+ // ENOENT on the missing item file. This can happen on a drifted custom
157
+ // `--registry` whose meta references a variant its index omits; the shipped
158
+ // registry is protected by check-registry's validateVariantItems gate.
159
+ if (!known.has(match.item)) {
160
+ errors.push(`${where}: variant "${wantVariant}" of block "${slug}" resolves to item "${match.item}", ` +
161
+ `which the registry index does not publish (regenerate/repair the registry).`);
162
+ return undefined;
163
+ }
164
+ // A matched variant carries its own registry item + export. The default
165
+ // variant's meta `item` equals the bare slug (so `variant` stays absent);
166
+ // a non-default variant records its id so the renderer imports from
167
+ // `<slug>--<id>`.
168
+ const isDefault = match.item === slug;
169
+ return {
170
+ slug,
171
+ item: match.item,
172
+ exportName: match.exportName,
173
+ kind: m.kind,
174
+ ...(isDefault ? {} : { variant: match.id }),
175
+ };
176
+ };
177
+ // --- Pages ---------------------------------------------------------------
178
+ const seenRoutes = new Set();
179
+ const planPages = [];
180
+ const chromeGroupsUsed = new Set();
181
+ // Family slugs a page/extras block references — the ONLY slots where a
182
+ // `--variant` override is consulted (inside resolveBlock). Chrome-slot slugs
183
+ // and slugs matching no slot are validated separately below so an override
184
+ // that had no effect is never silently accepted+persisted.
185
+ const variantTargetSlugs = new Set();
186
+ for (const page of selectedPages) {
187
+ const where = `page "${page.route}"`;
188
+ if (!isValidRoute(page.route)) {
189
+ errors.push(`${where}: invalid route (use "/" or lowercase "/a-z0-9-" / dynamic "[id]").`);
190
+ }
191
+ if (seenRoutes.has(page.route)) {
192
+ errors.push(`${where}: duplicate route.`);
193
+ }
194
+ seenRoutes.add(page.route);
195
+ if (!(page.chrome in body.chrome)) {
196
+ errors.push(`${where}: chrome group "${page.chrome}" is not defined in manifest.chrome.`);
197
+ }
198
+ else {
199
+ chromeGroupsUsed.add(page.chrome);
200
+ }
201
+ const blocks = [];
202
+ page.blocks.forEach((ref, i) => {
203
+ variantTargetSlugs.add(blockRefParts(ref).slug);
204
+ const resolved = resolveBlock(ref, `${where}.blocks[${i}]`);
205
+ if (resolved === undefined)
206
+ return;
207
+ // Semantic slot check: email never in a page; chrome only in a chrome slot.
208
+ if (resolved.kind === "email") {
209
+ errors.push(`${where}.blocks[${i}]: block "${resolved.slug}" is an email template — it cannot be a page section.`);
210
+ }
211
+ if (resolved.kind === "chrome") {
212
+ errors.push(`${where}.blocks[${i}]: block "${resolved.slug}" is layout chrome — it belongs in a chrome group, not a page.`);
213
+ }
214
+ blocks.push(resolved);
215
+ });
216
+ planPages.push({
217
+ route: page.route,
218
+ title: page.title,
219
+ ...(page.nav !== undefined ? { nav: page.nav } : {}),
220
+ chrome: page.chrome,
221
+ blocks,
222
+ });
223
+ }
224
+ // Dynamic-slug collisions Next.js rejects at build time, checked per chrome
225
+ // group (route groups are path-transparent, so sibling routes in the SAME group
226
+ // share a URL namespace):
227
+ // 1. Two routes with the same static skeleton but different dynamic-slug NAMES
228
+ // → "You cannot use different slug names for the same dynamic path".
229
+ // 2. A single route that repeats a slug name across its dynamic segments
230
+ // → "You cannot have the same slug name repeated within a single path".
231
+ // isValidRoute only validates each segment in isolation; seenRoutes only dedupes
232
+ // whole strings — neither catches these, so an otherwise-clean plan would fail
233
+ // `next build`. Grouped by chrome so identical routes under different groups (a
234
+ // real, valid layout split) are not falsely flagged.
235
+ const skeletonSlugByGroup = new Map();
236
+ for (const page of planPages) {
237
+ if (!isValidRoute(page.route))
238
+ continue; // already reported; skeleton is meaningless
239
+ // (2) repeated slug name within one path.
240
+ const dynSegs = page.route === "/" ? [] : page.route.slice(1).split("/").filter(isDynamicSegment);
241
+ const namesSeen = new Set();
242
+ for (const seg of dynSegs) {
243
+ const name = dynamicSlugName(seg);
244
+ if (namesSeen.has(name)) {
245
+ errors.push(`page "${page.route}": dynamic slug "[${name}]" is repeated in the same route.`);
246
+ }
247
+ namesSeen.add(name);
248
+ }
249
+ // (1) same skeleton, different slug name, within the same chrome group.
250
+ if (dynSegs.length === 0)
251
+ continue;
252
+ const skeleton = routeSkeleton(page.route);
253
+ const name = dynSegs.map(dynamicSlugName).join("/");
254
+ let bySkeleton = skeletonSlugByGroup.get(page.chrome);
255
+ if (bySkeleton === undefined) {
256
+ bySkeleton = new Map();
257
+ skeletonSlugByGroup.set(page.chrome, bySkeleton);
258
+ }
259
+ const prior = bySkeleton.get(skeleton);
260
+ if (prior !== undefined && prior.name !== name) {
261
+ errors.push(`page "${page.route}": dynamic slug name(s) differ from "${prior.route}" for the same route shape ` +
262
+ `— Next.js requires one slug name per dynamic path position.`);
263
+ }
264
+ else if (prior === undefined) {
265
+ bySkeleton.set(skeleton, { route: page.route, name });
266
+ }
267
+ }
268
+ // --- Chrome groups -------------------------------------------------------
269
+ const planChromes = [];
270
+ const chromeSlugsSet = new Set();
271
+ // Family slugs a chrome slot (navbar/footer/shell) references in a USED group,
272
+ // whether or not the ref resolved — chrome slots never consult `--variant`, so
273
+ // an override targeting one is rejected below rather than silently dropped.
274
+ const chromeSlotSlugs = new Set();
275
+ let usesNavbar = false;
276
+ let usesFooter = false;
277
+ let usesShell = false;
278
+ let navbarSlug;
279
+ let footerSlug;
280
+ let shellSlug;
281
+ // The renderer collapses the shell to SINGLE globals (one shellSlug + one
282
+ // AppShellNav wrapper scoped to one group). Two shell groups would both import
283
+ // that single wrapper and mis-render the sidebar nav of every group after the
284
+ // first, so only one shell group per app is supported. Count them to fail loud.
285
+ const shellGroups = [];
286
+ // Only groups actually used by an included page get a layout (sorted for stability).
287
+ for (const group of [...chromeGroupsUsed].sort()) {
288
+ const def = body.chrome[group];
289
+ if (def === undefined)
290
+ continue; // already reported above
291
+ const chrome = { group };
292
+ // Validate one chrome-block ref (navbar / footer / shell): the block must
293
+ // exist, have meta, and be kind "chrome". On success it records the slug in
294
+ // `chrome` + the shared wrapper globals. `slot` is only used for the error
295
+ // wording; the caller wires the resolved slug into the right field.
296
+ const checkChromeRef = (slug, slot) => {
297
+ if (!known.has(slug)) {
298
+ const suggestion = closestName(slug, blockNames);
299
+ errors.push(suggestion
300
+ ? `chrome "${group}".${slot}: unknown block "${slug}". Did you mean "${suggestion}"?`
301
+ : `chrome "${group}".${slot}: unknown block "${slug}".`);
302
+ return;
303
+ }
304
+ const m = meta.blocks[slug];
305
+ if (m === undefined) {
306
+ errors.push(`chrome "${group}".${slot}: block "${slug}" has no metadata.`);
307
+ return;
308
+ }
309
+ if (m.kind !== "chrome") {
310
+ errors.push(`chrome "${group}".${slot}: block "${slug}" is kind "${m.kind}" — only chrome blocks belong in a chrome slot.`);
311
+ return;
312
+ }
313
+ chromeSlugsSet.add(slug);
314
+ if (slot === "navbar") {
315
+ chrome.navbar = slug;
316
+ usesNavbar = true;
317
+ navbarSlug = slug;
318
+ }
319
+ else if (slot === "footer") {
320
+ chrome.footer = slug;
321
+ usesFooter = true;
322
+ footerSlug = slug;
323
+ }
324
+ else {
325
+ chrome.block = slug;
326
+ usesShell = true;
327
+ shellSlug = slug;
328
+ }
329
+ };
330
+ if (def.navbar !== undefined) {
331
+ chromeSlotSlugs.add(def.navbar);
332
+ checkChromeRef(def.navbar, "navbar");
333
+ }
334
+ if (def.footer !== undefined) {
335
+ chromeSlotSlugs.add(def.footer);
336
+ checkChromeRef(def.footer, "footer");
337
+ }
338
+ // The app-shell chrome block (sidebar shell): the (app)-group layout wraps
339
+ // {children} in it, exactly as (site) wraps SiteNav/SiteFooter. Mixing a
340
+ // shell with navbar/footer in one group is a design error — a shell IS the
341
+ // full page frame — so reject that combination up front.
342
+ if (def.block !== undefined) {
343
+ if (def.navbar !== undefined || def.footer !== undefined) {
344
+ errors.push(`chrome "${group}": a shell "block" cannot be combined with navbar/footer in the same group.`);
345
+ }
346
+ shellGroups.push(group);
347
+ chromeSlotSlugs.add(def.block);
348
+ checkChromeRef(def.block, "block");
349
+ }
350
+ planChromes.push(chrome);
351
+ }
352
+ // Only ONE shell group per app is supported: the renderer emits a single
353
+ // AppShellNav wrapper backed by one block copy scoped to one group, so a
354
+ // second shell group would silently render the first group's sidebar nav.
355
+ // Fail loud, mirroring the shell+navbar/footer guard above.
356
+ if (shellGroups.length > 1) {
357
+ const [, ...extras] = shellGroups;
358
+ for (const group of extras) {
359
+ errors.push(`chrome "${group}": only one shell "block" group is supported per app ` +
360
+ `(already used by "${shellGroups[0]}").`);
361
+ }
362
+ }
363
+ // --- Chrome sources + meta lookups (for the renderer) --------------------
364
+ const resolvedChromeSources = {};
365
+ const dataSlotsBySlug = {};
366
+ const brandTokensBySlug = {};
367
+ for (const slug of [...chromeSlugsSet].sort()) {
368
+ const source = chromeSources[slug];
369
+ if (source === undefined) {
370
+ errors.push(`chrome block "${slug}": shipped source is unavailable (registry read failed).`);
371
+ }
372
+ else {
373
+ resolvedChromeSources[slug] = source;
374
+ }
375
+ const m = meta.blocks[slug];
376
+ if (m !== undefined) {
377
+ dataSlotsBySlug[slug] = [...m.dataSlots];
378
+ brandTokensBySlug[slug] = m.brandTokens.map((b) => ({ ...b }));
379
+ }
380
+ }
381
+ // --- Extras (Next special-file blocks, e.g. not-found → app/not-found.tsx) ---
382
+ // Resolve each declared extras block the same way page blocks are, then require
383
+ // it to be a page-kind block (special files are full-page surfaces) and map its
384
+ // KEY to a supported Next special-file path. Resolved extras are installed and
385
+ // wrapped by the renderer under the golden rule. Sorted by key for determinism.
386
+ const planExtras = [];
387
+ const extras = body.extras ?? {};
388
+ for (const key of Object.keys(extras).sort()) {
389
+ const slug = extras[key];
390
+ if (slug === undefined)
391
+ continue; // never happens (own key); satisfies the checker
392
+ const where = `extras."${key}"`;
393
+ const file = EXTRAS_SPECIAL_FILES[key];
394
+ if (file === undefined) {
395
+ const suggestion = closestName(key, Object.keys(EXTRAS_SPECIAL_FILES));
396
+ errors.push(suggestion
397
+ ? `${where}: unsupported special file "${key}". Did you mean "${suggestion}"?`
398
+ : `${where}: unsupported special file "${key}" (supported: ${Object.keys(EXTRAS_SPECIAL_FILES).join(", ")}).`);
399
+ continue;
400
+ }
401
+ variantTargetSlugs.add(blockRefParts(slug).slug);
402
+ const resolved = resolveBlock(slug, where);
403
+ if (resolved === undefined)
404
+ continue; // resolveBlock already pushed the error
405
+ if (resolved.kind !== "page") {
406
+ errors.push(`${where}: block "${resolved.slug}" is kind "${resolved.kind}" — a Next special file needs a page-kind block.`);
407
+ continue;
408
+ }
409
+ planExtras.push({ key, file, slug: resolved.slug, exportName: resolved.exportName });
410
+ }
411
+ // --- Variant-override targets --------------------------------------------
412
+ // A `--variant slug=id` override is consulted ONLY inside resolveBlock, i.e.
413
+ // only for a family slug some included page/extras block references. If the
414
+ // slug is a chrome-slot slug (navbar/footer/shell), the override is silently
415
+ // ignored (chrome variants are out of F2 scope); if it matches no slot at all
416
+ // (typo / dead / nonexistent slug), it is silently ignored too. Either way the
417
+ // override is still persisted into choices.variants, producing a record that
418
+ // does not match the generated app. Cross-check every override key here so an
419
+ // override that had no effect fails loud instead — mirroring the clear
420
+ // did-you-mean error a page-block variant typo already gets.
421
+ for (const slug of Object.keys(variantOverrides).sort()) {
422
+ if (variantTargetSlugs.has(slug))
423
+ continue; // applied by a page/extras block
424
+ if (chromeSlotSlugs.has(slug)) {
425
+ errors.push(`--variant "${slug}": chrome slots (navbar/footer/shell) do not accept --variant.`);
426
+ continue;
427
+ }
428
+ const suggestion = closestName(slug, [...variantTargetSlugs]);
429
+ errors.push(suggestion
430
+ ? `--variant "${slug}": no included page/extras block uses block "${slug}". Did you mean "${suggestion}"?`
431
+ : `--variant "${slug}": no included page/extras block uses block "${slug}".`);
432
+ }
433
+ // --- Dependencies (arg-injection guard reuse) ----------------------------
434
+ // Collect the registry ITEM names to install (a non-default variant contributes
435
+ // its `<slug>--<variant>` item, not the bare slug), so resolve/install and the
436
+ // arg-injection guard both run over exactly what lands on disk. Chrome + extras
437
+ // are bare items (no variants in those slots in F2).
438
+ const allBlockItems = new Set();
439
+ for (const page of planPages)
440
+ for (const b of page.blocks)
441
+ allBlockItems.add(b.item);
442
+ for (const slug of chromeSlugsSet)
443
+ allBlockItems.add(slug);
444
+ for (const extra of planExtras)
445
+ allBlockItems.add(extra.slug);
446
+ for (const entry of index) {
447
+ if (!allBlockItems.has(entry.name))
448
+ continue;
449
+ for (const dep of entry.dependencies) {
450
+ try {
451
+ assertValidDependency(dep);
452
+ }
453
+ catch (err) {
454
+ errors.push(`block "${entry.name}": ${err.message}`);
455
+ }
456
+ }
457
+ }
458
+ const navbarMeta = navbarSlug !== undefined ? meta.blocks[navbarSlug] : undefined;
459
+ const footerMeta = footerSlug !== undefined ? meta.blocks[footerSlug] : undefined;
460
+ const shellMeta = shellSlug !== undefined ? meta.blocks[shellSlug] : undefined;
461
+ const plan = {
462
+ templateName: manifest.name,
463
+ appName,
464
+ planVersion: manifest.planVersion,
465
+ title: body.title,
466
+ description: body.description,
467
+ choices: normalizeChoices(manifest, input, planPages),
468
+ pages: planPages,
469
+ chromes: planChromes,
470
+ extras: planExtras,
471
+ blockSlugs: [...allBlockItems].sort(),
472
+ chromeSlugs: [...chromeSlugsSet].sort(),
473
+ chromeSources: resolvedChromeSources,
474
+ dataSlotsBySlug,
475
+ brandTokensBySlug,
476
+ usesNavbar,
477
+ usesFooter,
478
+ usesShell,
479
+ ...(navbarSlug !== undefined ? { navbarSlug } : {}),
480
+ ...(footerSlug !== undefined ? { footerSlug } : {}),
481
+ ...(shellSlug !== undefined ? { shellSlug } : {}),
482
+ ...(navbarMeta !== undefined ? { navbarExportName: navbarMeta.exportName } : {}),
483
+ ...(footerMeta !== undefined ? { footerExportName: footerMeta.exportName } : {}),
484
+ ...(shellMeta !== undefined ? { shellExportName: shellMeta.exportName } : {}),
485
+ };
486
+ if (errors.length > 0)
487
+ throw new ComposePlanError(errors);
488
+ return plan;
489
+ }
490
+ /**
491
+ * Validate-only entry point: builds the plan and returns the aggregated error
492
+ * list (empty when valid) instead of throwing. Handy for `--dry-run` callers and
493
+ * tests that assert on the full error set. The successful plan is returned too.
494
+ */
495
+ export function validateComposePlan(manifest, input, index, meta, chromeSources) {
496
+ try {
497
+ const plan = buildComposePlan(manifest, input, index, meta, chromeSources);
498
+ return { plan, errors: [] };
499
+ }
500
+ catch (err) {
501
+ if (err instanceof ComposePlanError)
502
+ return { errors: err.errors };
503
+ throw err;
504
+ }
505
+ }
506
+ //# sourceMappingURL=plan.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Deterministic textual preview of a compose plan (the `--dry-run` human view):
3
+ * a sitemap tree, the block stack + chrome per page, the nav labels, and the
4
+ * theme/brand. Pure — a function of the plan only, no I/O/Date — so it reads the
5
+ * same every run and can be snapshot-tested.
6
+ */
7
+ import type { ComposePlan } from "./plan.js";
8
+ /** Render the plan to a multi-line preview string (no trailing newline). */
9
+ export declare function renderPreview(plan: ComposePlan): string;
10
+ //# sourceMappingURL=preview.d.ts.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Deterministic textual preview of a compose plan (the `--dry-run` human view):
3
+ * a sitemap tree, the block stack + chrome per page, the nav labels, and the
4
+ * theme/brand. Pure — a function of the plan only, no I/O/Date — so it reads the
5
+ * same every run and can be snapshot-tested.
6
+ */
7
+ /** Render the plan to a multi-line preview string (no trailing newline). */
8
+ export function renderPreview(plan) {
9
+ const lines = [];
10
+ lines.push(`App: ${plan.title} (${plan.appName})`);
11
+ if (plan.description.length > 0)
12
+ lines.push(` ${plan.description}`);
13
+ lines.push(`Brand: ${plan.choices.brand}`);
14
+ if (plan.choices.seed !== undefined)
15
+ lines.push(`Seed: ${plan.choices.seed}`);
16
+ // Chrome groups.
17
+ lines.push("");
18
+ lines.push("Chrome:");
19
+ for (const chrome of plan.chromes) {
20
+ const parts = [];
21
+ if (chrome.navbar !== undefined)
22
+ parts.push(`navbar=${chrome.navbar}`);
23
+ if (chrome.footer !== undefined)
24
+ parts.push(`footer=${chrome.footer}`);
25
+ if (chrome.block !== undefined)
26
+ parts.push(`shell=${chrome.block}`);
27
+ lines.push(` (${chrome.group}) ${parts.length > 0 ? parts.join(" ") : "bare"}`);
28
+ }
29
+ // Sitemap: one entry per page, with its block stack.
30
+ lines.push("");
31
+ lines.push("Pages:");
32
+ for (const page of plan.pages) {
33
+ const navMark = page.nav !== undefined ? ` [nav: ${page.nav}]` : "";
34
+ lines.push(` ${page.route} "${page.title}" (${page.chrome})${navMark}`);
35
+ for (const block of page.blocks) {
36
+ const variantMark = block.variant !== undefined ? ` (variant: ${block.variant})` : "";
37
+ lines.push(` - ${block.slug}${variantMark} <${block.exportName}> (${block.kind})`);
38
+ }
39
+ }
40
+ // Nav labels (in order).
41
+ const navLabels = plan.pages
42
+ .filter((p) => p.nav !== undefined)
43
+ .map((p) => `${p.nav} → ${p.route}`);
44
+ lines.push("");
45
+ lines.push(`Nav: ${navLabels.length > 0 ? navLabels.join(", ") : "(none)"}`);
46
+ return lines.join("\n");
47
+ }
48
+ //# sourceMappingURL=preview.js.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Compose reload + base-snapshot helpers shared by add-page and upgrade.
3
+ *
4
+ * Snapshots live at `.cronus-ui/base/<composedKey>/` (the `composed{}` key =
5
+ * template name). Pre-F4 compose wrote them under the package.json name
6
+ * (`plan.appName`); readers fall back to that dir when the composed-key dir
7
+ * (or a given file in it) is missing. New writes always use the composed key.
8
+ */
9
+ import type { ComposedRecord } from "../config.js";
10
+ import { type AppManifest } from "./manifest.js";
11
+ /** Base-snapshot directory for an app's generated bytes (F4 merge base). */
12
+ export declare function baseSnapshotDir(appKey: string): string;
13
+ /**
14
+ * Absolute dest of a snapshot file for a NEW write. Always the composed key —
15
+ * never the legacy package-name dir.
16
+ */
17
+ export declare function baseSnapshotDest(cwd: string, composedKey: string, relPath: string): string;
18
+ /**
19
+ * Read one snapshot file. Prefers `.cronus-ui/base/<composedKey>/<rel>`; if that
20
+ * file is missing, falls back to `.cronus-ui/base/<appName>/<rel>` (legacy
21
+ * compose). Returns undefined when neither exists.
22
+ */
23
+ export declare function readBaseSnapshot(cwd: string, composedKey: string, appName: string, relPath: string): Promise<string | undefined>;
24
+ /**
25
+ * Rel paths present in the snapshot tree(s). Unions the composed-key dir with
26
+ * the legacy appName dir when both exist, so a mixed pre/post-fix project
27
+ * still sees every snapshotted file.
28
+ */
29
+ export declare function listBaseSnapshotRels(cwd: string, composedKey: string, appName: string): Promise<string[]>;
30
+ /**
31
+ * Reload the app's manifest: an explicit `--manifest` file wins, else the bundled
32
+ * template whose name equals the composed key. A `--manifest`-composed app whose
33
+ * name is not a bundled template must re-supply `--manifest` (its manifest is not
34
+ * recoverable from `composed{}` alone) — we fail loud with that hint.
35
+ *
36
+ * PROVENANCE GUARD: the bundled fallback is keyed only on the app NAME, but a
37
+ * `--manifest`-composed app is keyed by the manifest's own `name` field, which can
38
+ * COLLIDE with a bundled template name (store/landing/saas). In that case
39
+ * `loadTemplate` silently returns the WRONG (bundled) manifest, and a re-plan
40
+ * would then drop every composed route/chrome group that the bundled template does
41
+ * not declare — silently corrupting the nav + composed record. So we verify the
42
+ * reloaded bundled template's content fingerprint against the compose-time
43
+ * `manifestHash` provenance recorded in `composed{}` and fail loud on a mismatch,
44
+ * demanding `--manifest`. A legacy record (composed before provenance existed) has
45
+ * no hash to check, so that case falls back to the prior lenient behavior.
46
+ */
47
+ export declare function reloadManifest(appName: string, manifestPath: string | undefined, composed: ComposedRecord): Promise<AppManifest>;
48
+ /**
49
+ * Restrict a reloaded manifest to the pages this app actually composed
50
+ * (`composed.choices.pages`, preserving manifest order). Add-page grafts that
51
+ * are not in the bundled/reloaded template are omitted from the synthetic
52
+ * manifest (they have no upstream render) — callers must keep them in
53
+ * `choices.pages` and on disk.
54
+ */
55
+ export declare function filterManifestToComposedPages(base: AppManifest, composed: ComposedRecord): AppManifest;
56
+ //# sourceMappingURL=reload.d.ts.map