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.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/commands/add-page.d.ts +108 -0
- package/dist/commands/add-page.js +642 -0
- package/dist/commands/add.d.ts +9 -0
- package/dist/commands/add.js +114 -0
- package/dist/commands/ai.d.ts +14 -0
- package/dist/commands/ai.js +69 -0
- package/dist/commands/compose.d.ts +82 -0
- package/dist/commands/compose.js +403 -0
- package/dist/commands/diff.d.ts +8 -0
- package/dist/commands/diff.js +55 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +53 -0
- package/dist/commands/list.d.ts +7 -0
- package/dist/commands/list.js +28 -0
- package/dist/commands/theme.d.ts +23 -0
- package/dist/commands/theme.js +735 -0
- package/dist/commands/upgrade.d.ts +51 -0
- package/dist/commands/upgrade.js +840 -0
- package/dist/compose/data-slots.d.ts +71 -0
- package/dist/compose/data-slots.js +104 -0
- package/dist/compose/manifest.d.ts +90 -0
- package/dist/compose/manifest.js +224 -0
- package/dist/compose/plan.d.ts +164 -0
- package/dist/compose/plan.js +506 -0
- package/dist/compose/preview.d.ts +10 -0
- package/dist/compose/preview.js +48 -0
- package/dist/compose/reload.d.ts +56 -0
- package/dist/compose/reload.js +138 -0
- package/dist/compose/render.d.ts +123 -0
- package/dist/compose/render.js +404 -0
- package/dist/compose/templates.d.ts +22 -0
- package/dist/compose/templates.js +76 -0
- package/dist/compose.d.ts +10 -0
- package/dist/compose.js +8 -0
- package/dist/config.d.ts +94 -0
- package/dist/config.js +38 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +184 -0
- package/dist/registry.d.ts +59 -0
- package/dist/registry.js +96 -0
- package/dist/utils.d.ts +72 -0
- package/dist/utils.js +186 -0
- package/package.json +68 -0
- package/templates/apps/chat.json +44 -0
- package/templates/apps/finance.json +44 -0
- package/templates/apps/landing-agency.json +31 -0
- package/templates/apps/landing-agents.json +32 -0
- package/templates/apps/landing-broadcast.json +29 -0
- package/templates/apps/landing-care.json +28 -0
- package/templates/apps/landing-coverage.json +23 -0
- package/templates/apps/landing-docs.json +29 -0
- package/templates/apps/landing-glass.json +28 -0
- package/templates/apps/landing-ops.json +29 -0
- package/templates/apps/landing-premium.json +31 -0
- package/templates/apps/landing-secure.json +31 -0
- package/templates/apps/landing-shop.json +27 -0
- package/templates/apps/landing-studio.json +30 -0
- package/templates/apps/landing.json +23 -0
- package/templates/apps/mail.json +44 -0
- package/templates/apps/saas.json +64 -0
- 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
|