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,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cronus-ui add-page` — incrementally add ONE page to an already-composed app
|
|
3
|
+
* (D3 graft, F2). It reuses the exact compose plan/render/install core: it
|
|
4
|
+
* reconstructs the app's manifest (chrome + already-composed pages), appends the
|
|
5
|
+
* new page, re-plans + re-validates the WHOLE app (so the new page's chrome ref,
|
|
6
|
+
* route uniqueness, and block kinds are checked against the real chrome and its
|
|
7
|
+
* siblings), then applies ONLY the incremental delta:
|
|
8
|
+
* - install any new blocks the page needs (unchanged install core);
|
|
9
|
+
* - write the new page.tsx (+ its layout/chrome wrappers when the page uses a
|
|
10
|
+
* chrome group the app did not have yet);
|
|
11
|
+
* - RE-INJECT the affected chrome block's nav data-slot so the new `--nav`
|
|
12
|
+
* entry appears in the navbar/footer/sidebar;
|
|
13
|
+
* - grow `composed{}.choices.pages` + `composed{}.files` (union) + the base
|
|
14
|
+
* snapshot for the new/rewritten files.
|
|
15
|
+
*
|
|
16
|
+
* It never re-writes untouched sibling pages (their bytes are unchanged), and it
|
|
17
|
+
* fails loud when there is no composed app, the app cannot be reloaded, or the
|
|
18
|
+
* route collides (unless `--overwrite`). Determinism + the golden rule are
|
|
19
|
+
* inherited wholesale from the shared renderer.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { rm } from "node:fs/promises";
|
|
23
|
+
import { blockRefParts } from "../compose/manifest.js";
|
|
24
|
+
import { buildComposePlan, ComposePlanError, } from "../compose/plan.js";
|
|
25
|
+
import { baseSnapshotDir, reloadManifest } from "../compose/reload.js";
|
|
26
|
+
import { chromeWrapperPath, layoutPath, pagePath, renderChromeWrapper, renderLayout, renderPage, renderShellWrapper, rewriteChromeBlock, } from "../compose/render.js";
|
|
27
|
+
import { CLI_VERSION, hasConfig, readConfig, writeConfig, } from "../config.js";
|
|
28
|
+
import { Registry, registrySourceVersion } from "../registry.js";
|
|
29
|
+
import { closestName, collectDependencies, detectPackageManager, log, resolveSafeDest, runInstall, writeFileEnsured, writeItemFiles, } from "../utils.js";
|
|
30
|
+
import { readChromeSources, readComposeMeta } from "./compose.js";
|
|
31
|
+
/** Title-case a route into a default page title: "/faq" → "Faq", "/help-center" → "Help Center". */
|
|
32
|
+
function defaultTitle(route) {
|
|
33
|
+
const last = route.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? "Home";
|
|
34
|
+
return last
|
|
35
|
+
.replace(/\[(\.\.\.)?([^\]]+)\]/g, "$2")
|
|
36
|
+
.split(/[-_]/)
|
|
37
|
+
.filter((s) => s.length > 0)
|
|
38
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
39
|
+
.join(" ");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Pick the composed app to extend, returning its name AND record. When `app` is
|
|
43
|
+
* given it must exist; otherwise the project must have exactly one composed app
|
|
44
|
+
* (the unambiguous default). Throws a clear, listing error on none / ambiguity.
|
|
45
|
+
* Returning the record here keeps the (guaranteed-present) lookup in one place so
|
|
46
|
+
* callers never re-assert on `config.composed`.
|
|
47
|
+
*/
|
|
48
|
+
function pickComposedApp(config, app) {
|
|
49
|
+
const composed = config.composed ?? {};
|
|
50
|
+
const keys = Object.keys(composed).sort();
|
|
51
|
+
if (keys.length === 0) {
|
|
52
|
+
throw new Error("No composed app found. Run `cronus-ui compose <template>` first.");
|
|
53
|
+
}
|
|
54
|
+
const pick = (name) => {
|
|
55
|
+
const record = composed[name];
|
|
56
|
+
if (record === undefined) {
|
|
57
|
+
// Unreachable: `name` is always one of `keys`. Narrows the type for TS.
|
|
58
|
+
throw new Error(`Internal: composed record for "${name}" is missing.`);
|
|
59
|
+
}
|
|
60
|
+
return { name, record };
|
|
61
|
+
};
|
|
62
|
+
if (app !== undefined) {
|
|
63
|
+
if (!(app in composed)) {
|
|
64
|
+
const suggestion = closestName(app, keys);
|
|
65
|
+
throw new Error(suggestion
|
|
66
|
+
? `Unknown composed app "${app}". Did you mean "${suggestion}"? (composed: ${keys.join(", ")})`
|
|
67
|
+
: `Unknown composed app "${app}" (composed: ${keys.join(", ")}).`);
|
|
68
|
+
}
|
|
69
|
+
return pick(app);
|
|
70
|
+
}
|
|
71
|
+
if (keys.length > 1) {
|
|
72
|
+
throw new Error(`This project has ${keys.length} composed apps (${keys.join(", ")}). Pass --app <name> to choose one.`);
|
|
73
|
+
}
|
|
74
|
+
return pick(keys[0]);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build the synthetic manifest for the re-plan: the reloaded manifest's chrome +
|
|
78
|
+
* defaults, its pages FILTERED to the ones this app actually composed (from
|
|
79
|
+
* `composed.choices.pages`, preserving manifest order), plus the appended new
|
|
80
|
+
* page. Re-planning this whole set validates the new page against the real chrome
|
|
81
|
+
* and its live siblings, and yields the full nav set for the chrome re-injection.
|
|
82
|
+
* Throws when the new route already belongs to the app (unless allowed by the
|
|
83
|
+
* caller's `--overwrite`, checked at write time — a duplicate route in the plan
|
|
84
|
+
* would otherwise be a hard plan error).
|
|
85
|
+
*/
|
|
86
|
+
function synthesizeManifest(base, composed, newPage, allowExisting) {
|
|
87
|
+
const composedRoutes = new Set(composed.choices.pages);
|
|
88
|
+
const keptPages = base.manifest.pages.filter((p) => composedRoutes.has(p.route));
|
|
89
|
+
const alreadyPresent = composedRoutes.has(newPage.route);
|
|
90
|
+
// With --overwrite we replace the existing page in place (drop the old ref so
|
|
91
|
+
// the plan has no duplicate route); otherwise the new page is appended and the
|
|
92
|
+
// caller enforces the collision policy before writing.
|
|
93
|
+
const pages = allowExisting
|
|
94
|
+
? [...keptPages.filter((p) => p.route !== newPage.route), newPage]
|
|
95
|
+
: [...keptPages, newPage];
|
|
96
|
+
const manifest = {
|
|
97
|
+
...base,
|
|
98
|
+
manifest: { ...base.manifest, pages },
|
|
99
|
+
};
|
|
100
|
+
return { manifest, alreadyPresent };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The variant overrides to feed the re-plan. The recorded `choices.variants` are
|
|
104
|
+
* the app's stale FAMILY-WIDE defaults from a prior compose; they exist only so
|
|
105
|
+
* re-rendered sibling/chrome nav stays consistent. But the NEW page's own inline
|
|
106
|
+
* `{ block, variant }` refs are the fresh user intent (`--blocks login=split`),
|
|
107
|
+
* which must WIN over any recorded family override for the same slug — otherwise
|
|
108
|
+
* `resolveBlock`'s `variantOverrides[slug] ?? manifestVariant` precedence would
|
|
109
|
+
* silently discard the request and emit the wrong variant. So we strip from the
|
|
110
|
+
* recorded overrides every slug the new page references with its OWN explicit
|
|
111
|
+
* variant, letting that ref-declared variant flow through as `manifestVariant`.
|
|
112
|
+
*/
|
|
113
|
+
function replanVariants(recorded, newPageBlocks) {
|
|
114
|
+
const inlineVariantSlugs = new Set();
|
|
115
|
+
for (const ref of newPageBlocks) {
|
|
116
|
+
const { slug, variant } = blockRefParts(ref);
|
|
117
|
+
if (variant !== undefined)
|
|
118
|
+
inlineVariantSlugs.add(slug);
|
|
119
|
+
}
|
|
120
|
+
if (inlineVariantSlugs.size === 0)
|
|
121
|
+
return recorded;
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const [slug, variant] of Object.entries(recorded)) {
|
|
124
|
+
if (!inlineVariantSlugs.has(slug))
|
|
125
|
+
out[slug] = variant;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Add one page to an already-composed app. Reloads the manifest, appends the new
|
|
131
|
+
* page, re-plans + re-validates the whole app, then writes ONLY the incremental
|
|
132
|
+
* delta (new page + any new layout/wrapper + the re-injected chrome nav) and
|
|
133
|
+
* grows `composed{}` + the base snapshot. Throws {@link ComposePlanError} /
|
|
134
|
+
* {@link Error} on invalid input.
|
|
135
|
+
*/
|
|
136
|
+
export async function addPage(options) {
|
|
137
|
+
const { targetDir } = options;
|
|
138
|
+
if (!hasConfig(targetDir)) {
|
|
139
|
+
throw new Error("No cronus-ui.json found. Run `cronus-ui init` first.");
|
|
140
|
+
}
|
|
141
|
+
const config = await readConfig(targetDir);
|
|
142
|
+
const { name: appName, record: composedRecord } = pickComposedApp(config, options.app);
|
|
143
|
+
const overwrite = options.overwrite ?? false;
|
|
144
|
+
const base = await reloadManifest(appName, options.manifestPath, composedRecord);
|
|
145
|
+
// Default the chrome group to the manifest's first (matches "no questions").
|
|
146
|
+
const firstChrome = Object.keys(base.manifest.chrome)[0];
|
|
147
|
+
const chromeGroup = options.chrome ?? firstChrome;
|
|
148
|
+
if (chromeGroup === undefined) {
|
|
149
|
+
throw new Error(`App "${appName}" defines no chrome groups.`);
|
|
150
|
+
}
|
|
151
|
+
const newPage = {
|
|
152
|
+
route: options.route,
|
|
153
|
+
title: options.title ?? defaultTitle(options.route),
|
|
154
|
+
...(options.nav !== undefined ? { nav: options.nav } : {}),
|
|
155
|
+
chrome: chromeGroup,
|
|
156
|
+
blocks: options.blocks,
|
|
157
|
+
};
|
|
158
|
+
const { manifest, alreadyPresent } = synthesizeManifest(base, composedRecord, newPage, overwrite);
|
|
159
|
+
if (alreadyPresent && !overwrite) {
|
|
160
|
+
throw new Error(`Route "${options.route}" is already a page of app "${appName}" — pass --overwrite to replace it.`);
|
|
161
|
+
}
|
|
162
|
+
const sourceUsed = options.registry ?? config.registry;
|
|
163
|
+
const registry = new Registry(sourceUsed);
|
|
164
|
+
const meta = await readComposeMeta(registry);
|
|
165
|
+
if (meta === null) {
|
|
166
|
+
throw new Error("This registry does not ship a meta.json sidecar — add-page needs it (upgrade to v0.4.0+).");
|
|
167
|
+
}
|
|
168
|
+
const index = await registry.index();
|
|
169
|
+
const chromeSources = await readChromeSources(manifest, registry);
|
|
170
|
+
// Re-plan the whole app (chrome + all composed pages + the new page) with the
|
|
171
|
+
// app's recorded choices (brand/seed/variants) so the chrome nav re-injection
|
|
172
|
+
// and every semantic check run against the real, complete plan. The brand is
|
|
173
|
+
// taken from the record verbatim (it was already resolved at compose time), so
|
|
174
|
+
// the re-injected chrome copy keeps the same wordmark. A plan error propagates
|
|
175
|
+
// as a ComposePlanError with its aggregated list intact.
|
|
176
|
+
const choices = {
|
|
177
|
+
brand: composedRecord.choices.brand,
|
|
178
|
+
...(composedRecord.choices.seed !== undefined ? { seed: composedRecord.choices.seed } : {}),
|
|
179
|
+
// The new page's inline `slug=variant` refs win over the app's recorded
|
|
180
|
+
// family overrides (see replanVariants) so `--blocks login=split` is honored.
|
|
181
|
+
variants: replanVariants(composedRecord.choices.variants, newPage.blocks),
|
|
182
|
+
};
|
|
183
|
+
const plan = buildComposePlan(manifest, choices, index, meta, chromeSources);
|
|
184
|
+
const planPage = plan.pages.find((p) => p.route === options.route);
|
|
185
|
+
if (planPage === undefined) {
|
|
186
|
+
// Should not happen: the new page is always in the synthetic manifest.
|
|
187
|
+
throw new Error(`Internal: planned page for route "${options.route}" not found.`);
|
|
188
|
+
}
|
|
189
|
+
const planChrome = plan.chromes.find((c) => c.group === chromeGroup);
|
|
190
|
+
// Capture this ONCE, before ensureChromeScaffold writes the layout — every
|
|
191
|
+
// downstream step (chrome-block install, scaffold, chrome rewrite) keys off the
|
|
192
|
+
// SAME "is this group brand-new?" answer so they stay lockstep.
|
|
193
|
+
const newChromeGroup = planChrome !== undefined && isNewChromeGroup(planChrome, targetDir);
|
|
194
|
+
const installedVersion = registrySourceVersion(sourceUsed) ?? CLI_VERSION;
|
|
195
|
+
// --- Install the new page's blocks (unchanged install core) ---------------
|
|
196
|
+
// Only the ITEMS this page introduces; sibling pages' blocks are already on
|
|
197
|
+
// disk (installed{}). resolve() over the page items pulls their deps too.
|
|
198
|
+
const pageItems = [...new Set(planPage.blocks.map((b) => b.item))];
|
|
199
|
+
const installed = { ...config.installed };
|
|
200
|
+
const installedBlocks = [];
|
|
201
|
+
// Resolve once (transitive deps included) and reuse for the npm-dep step. The
|
|
202
|
+
// list grows below with a brand-new chrome group's blocks so their deps ship too.
|
|
203
|
+
const resolvedItems = pageItems.length > 0 ? await registry.resolve(pageItems) : [];
|
|
204
|
+
// --- Install a BRAND-NEW chrome group's chrome block(s) -------------------
|
|
205
|
+
// When the page introduces a chrome group the app never scaffolded (its layout
|
|
206
|
+
// does not exist yet), ensureChromeScaffold below emits a wrapper/layout that
|
|
207
|
+
// imports `@/components/blocks/<navbar|footer|app-shell-chrome>` — but those
|
|
208
|
+
// chrome block files are NOT among the page's content blocks, so without this
|
|
209
|
+
// step they are never written and the generated app fails `next build` on an
|
|
210
|
+
// unresolvable import. compose installs them via plan.blockSlugs (which include
|
|
211
|
+
// chrome slugs); add-page narrows to the page's own blocks, so we install the
|
|
212
|
+
// group's chrome slugs here, mirroring the same resolve+writeItemFiles path.
|
|
213
|
+
if (newChromeGroup && planChrome !== undefined) {
|
|
214
|
+
const chromeItems = chromeSlugsOfGroup(planChrome).filter((slug) => installed[slug] === undefined);
|
|
215
|
+
if (chromeItems.length > 0) {
|
|
216
|
+
resolvedItems.push(...(await registry.resolve(chromeItems)));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const item of resolvedItems) {
|
|
220
|
+
const { written } = await writeItemFiles(item, config, targetDir, { overwrite });
|
|
221
|
+
if (written.length === item.files.length && written.length > 0) {
|
|
222
|
+
installed[item.name] = { version: installedVersion, files: written };
|
|
223
|
+
if (item.type === "registry:block")
|
|
224
|
+
installedBlocks.push(item.name);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const generatedFiles = [];
|
|
228
|
+
const skippedFiles = [];
|
|
229
|
+
// --- Write the new page.tsx (collision-safe) ------------------------------
|
|
230
|
+
const pageRel = pagePath(planPage);
|
|
231
|
+
const pageDest = resolveSafeDest(targetDir, ".", pageRel);
|
|
232
|
+
if (existsSync(pageDest) && !overwrite) {
|
|
233
|
+
skippedFiles.push(pageRel);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const content = renderPage(planPage, config);
|
|
237
|
+
await writeFileEnsured(pageDest, content);
|
|
238
|
+
generatedFiles.push(pageRel);
|
|
239
|
+
await writeFileEnsured(resolveSafeDest(targetDir, baseSnapshotDir(appName), pageRel), content);
|
|
240
|
+
}
|
|
241
|
+
// --- Cross-group overwrite: remove the route's OLD page.tsx ----------------
|
|
242
|
+
// When `--overwrite` MOVES an already-composed route to a DIFFERENT chrome
|
|
243
|
+
// group, the new page.tsx lands at app/(<newGroup>)/<route>/page.tsx while the
|
|
244
|
+
// previously-generated app/(<oldGroup>)/<route>/page.tsx stays on disk — Next.js
|
|
245
|
+
// then rejects the build ("two parallel pages that resolve to the same path").
|
|
246
|
+
// Delete the stale file (+ its base snapshot) and drop it from the tracked file
|
|
247
|
+
// set so the record stays 1:1 with disk. In-group overwrite is a no-op here
|
|
248
|
+
// (same path, replaced in place above).
|
|
249
|
+
const removedOldPage = await removeMovedPage(overwrite && alreadyPresent, base, options.route, chromeGroup, pageRel, targetDir, appName);
|
|
250
|
+
// --- Ensure the chrome group's layout + wrappers exist --------------------
|
|
251
|
+
// If the new page uses a chrome group the app did not have yet, its layout and
|
|
252
|
+
// chrome wrappers were never generated — emit them now. An established group's
|
|
253
|
+
// layout is unchanged (thin, nav-agnostic), so this no-ops silently for it.
|
|
254
|
+
if (planChrome !== undefined) {
|
|
255
|
+
await ensureChromeScaffold(plan, planChrome, config, targetDir, appName, generatedFiles);
|
|
256
|
+
}
|
|
257
|
+
// --- Customize the chrome block(s): nav data-slot + brand -----------------
|
|
258
|
+
// Two triggers: (1) `--nav` grows an EXISTING group's nav set, so its chrome
|
|
259
|
+
// block(s) must be re-rendered with the larger nav; (2) a BRAND-NEW group whose
|
|
260
|
+
// chrome block we just installed pristine — it still carries the placeholder
|
|
261
|
+
// nav const + the "Cronus" brand literal, so it MUST be customized to match what
|
|
262
|
+
// compose emits (brand replaced, nav data filled), else the new group ships an
|
|
263
|
+
// uncustomized chrome. We rewrite from the PRISTINE registry source (exactly as
|
|
264
|
+
// compose does): the brand literal ("Cronus") only exists in the pristine source
|
|
265
|
+
// — the on-disk copy already replaced it with the app brand, so re-running the
|
|
266
|
+
// brand pass over the on-disk copy would fail to find the anchor. Rewriting the
|
|
267
|
+
// pristine source re-applies BOTH the nav data-slot and the brand, yielding the
|
|
268
|
+
// same bytes compose would. HONEST CUT: like compose's chrome customization,
|
|
269
|
+
// this regenerates the chrome copy and does not preserve hand edits to it.
|
|
270
|
+
const rewriteChrome = options.nav !== undefined || newChromeGroup;
|
|
271
|
+
if (rewriteChrome && planChrome !== undefined) {
|
|
272
|
+
for (const slug of chromeSlugsOfGroup(planChrome)) {
|
|
273
|
+
const rel = `${config.paths.blocks}/${slug}.tsx`;
|
|
274
|
+
const dest = resolveSafeDest(targetDir, ".", rel);
|
|
275
|
+
const source = plan.chromeSources[slug];
|
|
276
|
+
if (source === undefined || !existsSync(dest))
|
|
277
|
+
continue;
|
|
278
|
+
const content = rewriteChromeBlock(slug, source, plan);
|
|
279
|
+
await writeFileEnsured(dest, content);
|
|
280
|
+
if (!generatedFiles.includes(rel))
|
|
281
|
+
generatedFiles.push(rel);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// --- Grow composed{} (pages union + files union) + persist ----------------
|
|
285
|
+
const nextPages = [...new Set([...composedRecord.choices.pages, options.route])];
|
|
286
|
+
// Preserve manifest order of the routes the app actually has.
|
|
287
|
+
const orderedRoutes = plan.pages.map((p) => p.route).filter((r) => nextPages.includes(r));
|
|
288
|
+
const nextChoices = { ...composedRecord.choices, pages: orderedRoutes };
|
|
289
|
+
// Drop the moved-away old page path from the tracked set (it was just deleted).
|
|
290
|
+
const priorFiles = removedOldPage !== undefined
|
|
291
|
+
? composedRecord.files.filter((f) => f !== removedOldPage)
|
|
292
|
+
: composedRecord.files;
|
|
293
|
+
const nextRecord = {
|
|
294
|
+
...composedRecord,
|
|
295
|
+
version: installedVersion,
|
|
296
|
+
choices: nextChoices,
|
|
297
|
+
files: [...new Set([...priorFiles, ...generatedFiles])].sort(),
|
|
298
|
+
};
|
|
299
|
+
const composed = { ...config.composed, [appName]: nextRecord };
|
|
300
|
+
await writeConfig(targetDir, { ...config, installed, composed });
|
|
301
|
+
// --- Install npm deps (best-effort) ---------------------------------------
|
|
302
|
+
if (resolvedItems.length > 0 && !(options.skipInstall ?? false)) {
|
|
303
|
+
const deps = collectDependencies(resolvedItems);
|
|
304
|
+
if (deps.length > 0) {
|
|
305
|
+
const pm = detectPackageManager(targetDir);
|
|
306
|
+
try {
|
|
307
|
+
await runInstall(pm, deps, targetDir);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Non-fatal.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { appName, route: options.route, generatedFiles, skippedFiles, installedBlocks };
|
|
315
|
+
}
|
|
316
|
+
/** Chrome block slugs used by one group (navbar/footer/shell), in a stable order. */
|
|
317
|
+
function chromeSlugsOfGroup(chrome) {
|
|
318
|
+
const slugs = [];
|
|
319
|
+
if (chrome.navbar !== undefined)
|
|
320
|
+
slugs.push(chrome.navbar);
|
|
321
|
+
if (chrome.footer !== undefined)
|
|
322
|
+
slugs.push(chrome.footer);
|
|
323
|
+
if (chrome.block !== undefined)
|
|
324
|
+
slugs.push(chrome.block);
|
|
325
|
+
return slugs;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* True when the page's chrome group was NEVER scaffolded in this app — decided by
|
|
329
|
+
* its layout: an established group already has `app/(<group>)/layout.tsx`, a
|
|
330
|
+
* brand-new one does not. The single source of truth shared by the chrome-block
|
|
331
|
+
* install, ensureChromeScaffold, and the dry-run predictor so they stay lockstep.
|
|
332
|
+
*/
|
|
333
|
+
function isNewChromeGroup(chrome, targetDir) {
|
|
334
|
+
return !existsSync(resolveSafeDest(targetDir, ".", layoutPath(chrome)));
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Delete the previously-generated page.tsx when `--overwrite` MOVED a route to a
|
|
338
|
+
* different chrome group. The old chrome group is read from the reloaded base
|
|
339
|
+
* manifest's page for that route (the group it was composed under). Returns the
|
|
340
|
+
* project-relative path of the file it removed (so the caller drops it from the
|
|
341
|
+
* tracked file set), or `undefined` when nothing was moved/removed. No-op when the
|
|
342
|
+
* old and new group match (in-group overwrite replaces the file in place) or when
|
|
343
|
+
* the old file does not exist.
|
|
344
|
+
*/
|
|
345
|
+
async function removeMovedPage(active, base, route, newGroup, newPageRel, targetDir, appName) {
|
|
346
|
+
if (!active)
|
|
347
|
+
return undefined;
|
|
348
|
+
const oldPageDef = base.manifest.pages.find((p) => p.route === route);
|
|
349
|
+
const oldGroup = oldPageDef?.chrome;
|
|
350
|
+
if (oldGroup === undefined || oldGroup === newGroup)
|
|
351
|
+
return undefined;
|
|
352
|
+
// Build the old page path from route + old chrome (pagePath reads only those).
|
|
353
|
+
const oldRel = pagePath({ route, title: "", chrome: oldGroup, blocks: [] });
|
|
354
|
+
if (oldRel === newPageRel)
|
|
355
|
+
return undefined; // defensive: same path, nothing to remove
|
|
356
|
+
const oldDest = resolveSafeDest(targetDir, ".", oldRel);
|
|
357
|
+
if (existsSync(oldDest))
|
|
358
|
+
await rm(oldDest, { force: true });
|
|
359
|
+
const oldSnap = resolveSafeDest(targetDir, baseSnapshotDir(appName), oldRel);
|
|
360
|
+
if (existsSync(oldSnap))
|
|
361
|
+
await rm(oldSnap, { force: true });
|
|
362
|
+
return oldRel;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Ensure the layout + chrome wrappers for a page's chrome group exist on disk.
|
|
366
|
+
* The group's presence is decided by its layout: if `app/(<group>)/layout.tsx`
|
|
367
|
+
* already exists, the group is ESTABLISHED — its layout + wrappers are unchanged
|
|
368
|
+
* (thin, nav-agnostic), so we do nothing and add no skip noise. Only a page that
|
|
369
|
+
* introduces a BRAND-NEW chrome group triggers writing that group's layout +
|
|
370
|
+
* SiteNav/SiteFooter/AppShellNav wrappers now. Collision-safe: a stray pre-existing
|
|
371
|
+
* wrapper is silently left in place (no --overwrite).
|
|
372
|
+
*/
|
|
373
|
+
async function ensureChromeScaffold(plan, chrome, config, targetDir, appName, generatedFiles) {
|
|
374
|
+
// Established group → nothing to scaffold (its layout/wrappers already exist and
|
|
375
|
+
// are unchanged by adding a page).
|
|
376
|
+
if (!isNewChromeGroup(chrome, targetDir))
|
|
377
|
+
return;
|
|
378
|
+
const write = async (rel, content) => {
|
|
379
|
+
const dest = resolveSafeDest(targetDir, ".", rel);
|
|
380
|
+
if (existsSync(dest))
|
|
381
|
+
return; // leave a stray pre-existing wrapper untouched
|
|
382
|
+
await writeFileEnsured(dest, content);
|
|
383
|
+
generatedFiles.push(rel);
|
|
384
|
+
await writeFileEnsured(resolveSafeDest(targetDir, baseSnapshotDir(appName), rel), content);
|
|
385
|
+
};
|
|
386
|
+
// Chrome wrappers first (the layout imports them). Only emit for the furniture
|
|
387
|
+
// this group actually uses, mirroring renderPlan.
|
|
388
|
+
if (chrome.navbar !== undefined && plan.navbarExportName !== undefined) {
|
|
389
|
+
await write(chromeWrapperPath(config, "site-nav"), renderChromeWrapper("SiteNav", chrome.navbar, plan.navbarExportName, config));
|
|
390
|
+
}
|
|
391
|
+
if (chrome.footer !== undefined && plan.footerExportName !== undefined) {
|
|
392
|
+
await write(chromeWrapperPath(config, "site-footer"), renderChromeWrapper("SiteFooter", chrome.footer, plan.footerExportName, config));
|
|
393
|
+
}
|
|
394
|
+
if (chrome.block !== undefined && plan.shellExportName !== undefined) {
|
|
395
|
+
await write(chromeWrapperPath(config, "app-shell"), renderShellWrapper("AppShellNav", chrome.block, plan.shellExportName, config));
|
|
396
|
+
}
|
|
397
|
+
await write(layoutPath(chrome), renderLayout(chrome, config));
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Parse the `--blocks hero,cta` / `--blocks login=split,cta` spec into block refs.
|
|
401
|
+
* A `slug=variant` token becomes `{ block, variant }`; a bare token stays a string
|
|
402
|
+
* (default variant). Empty tokens are dropped; an empty spec throws.
|
|
403
|
+
*/
|
|
404
|
+
export function parseBlockRefs(spec) {
|
|
405
|
+
const tokens = (spec ?? "")
|
|
406
|
+
.split(",")
|
|
407
|
+
.map((s) => s.trim())
|
|
408
|
+
.filter((s) => s.length > 0);
|
|
409
|
+
if (tokens.length === 0) {
|
|
410
|
+
throw new Error("--blocks is required, e.g. --blocks faq,cta (or login=split).");
|
|
411
|
+
}
|
|
412
|
+
return tokens.map((tok) => {
|
|
413
|
+
const eq = tok.indexOf("=");
|
|
414
|
+
if (eq === -1)
|
|
415
|
+
return tok;
|
|
416
|
+
// Trim BOTH sides of the `=` so `login = split` (and stray inner spaces)
|
|
417
|
+
// resolve to { block: "login", variant: "split" } instead of failing plan
|
|
418
|
+
// validation with an unhelpful `unknown block "login "`.
|
|
419
|
+
const block = tok.slice(0, eq).trim();
|
|
420
|
+
const variant = tok.slice(eq + 1).trim();
|
|
421
|
+
if (block.length === 0 || variant.length === 0) {
|
|
422
|
+
throw new Error(`Invalid --blocks token "${tok}" (expected "slug" or "slug=variant").`);
|
|
423
|
+
}
|
|
424
|
+
return { block, variant };
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The `cronus-ui add-page` command: adds ONE page to a composed app, or previews
|
|
429
|
+
* it with `--dry-run` (writes nothing). Aggregated plan errors are printed as a
|
|
430
|
+
* list, like `compose`.
|
|
431
|
+
*/
|
|
432
|
+
export async function addPageCommand(options) {
|
|
433
|
+
const { cwd } = options;
|
|
434
|
+
if (!hasConfig(cwd)) {
|
|
435
|
+
log.err("No cronus-ui.json found. Run `cronus-ui init` first.");
|
|
436
|
+
process.exitCode = 1;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (options.route === undefined || options.route.length === 0) {
|
|
440
|
+
log.err("--route is required, e.g. `cronus-ui add-page --route /faq --blocks faq,cta`.");
|
|
441
|
+
process.exitCode = 1;
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const route = options.route.startsWith("/") ? options.route : `/${options.route}`;
|
|
445
|
+
let blocks;
|
|
446
|
+
try {
|
|
447
|
+
blocks = parseBlockRefs(options.blocks);
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
log.err(err.message);
|
|
451
|
+
process.exitCode = 1;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// --- Dry-run: plan + preview the single page, write nothing ---------------
|
|
455
|
+
if (options.dryRun === true) {
|
|
456
|
+
try {
|
|
457
|
+
const preview = await previewAddPage({
|
|
458
|
+
targetDir: cwd,
|
|
459
|
+
route,
|
|
460
|
+
blocks,
|
|
461
|
+
...(options.chrome !== undefined ? { chrome: options.chrome } : {}),
|
|
462
|
+
...(options.title !== undefined ? { title: options.title } : {}),
|
|
463
|
+
...(options.nav !== undefined ? { nav: options.nav } : {}),
|
|
464
|
+
...(options.app !== undefined ? { app: options.app } : {}),
|
|
465
|
+
...(options.manifest !== undefined ? { manifestPath: options.manifest } : {}),
|
|
466
|
+
...(options.registry !== undefined ? { registry: options.registry } : {}),
|
|
467
|
+
overwrite: options.overwrite ?? false,
|
|
468
|
+
});
|
|
469
|
+
log.title(`Add page (dry-run) — ${route} → app "${preview.appName}"`);
|
|
470
|
+
for (const f of preview.wouldWrite)
|
|
471
|
+
log.step(`Would write ${f}`);
|
|
472
|
+
if (preview.wouldInstall.length > 0) {
|
|
473
|
+
log.step(`Would install: ${preview.wouldInstall.join(", ")}`);
|
|
474
|
+
}
|
|
475
|
+
log.title("Nothing written (dry-run).");
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
reportError(err);
|
|
479
|
+
process.exitCode = 1;
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
// --- Apply ----------------------------------------------------------------
|
|
484
|
+
let result;
|
|
485
|
+
try {
|
|
486
|
+
result = await addPage({
|
|
487
|
+
targetDir: cwd,
|
|
488
|
+
route,
|
|
489
|
+
blocks,
|
|
490
|
+
...(options.chrome !== undefined ? { chrome: options.chrome } : {}),
|
|
491
|
+
...(options.title !== undefined ? { title: options.title } : {}),
|
|
492
|
+
...(options.nav !== undefined ? { nav: options.nav } : {}),
|
|
493
|
+
...(options.app !== undefined ? { app: options.app } : {}),
|
|
494
|
+
...(options.manifest !== undefined ? { manifestPath: options.manifest } : {}),
|
|
495
|
+
...(options.registry !== undefined ? { registry: options.registry } : {}),
|
|
496
|
+
overwrite: options.overwrite ?? false,
|
|
497
|
+
skipInstall: options.skipInstall ?? false,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
reportError(err);
|
|
502
|
+
process.exitCode = 1;
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
for (const path of result.generatedFiles)
|
|
506
|
+
log.ok(`Wrote ${path}`);
|
|
507
|
+
for (const path of result.skippedFiles)
|
|
508
|
+
log.warn(`Skipped ${path} (exists — use --overwrite)`);
|
|
509
|
+
if (result.installedBlocks.length > 0) {
|
|
510
|
+
log.step(`Installed ${result.installedBlocks.length} block(s): ${result.installedBlocks.join(", ")}`);
|
|
511
|
+
}
|
|
512
|
+
log.title(`Added ${result.route} to "${result.appName}".`);
|
|
513
|
+
log.title("Next steps");
|
|
514
|
+
log.step("npx cronus-ui add-page --route /pricing --blocks pricing,cta --nav Pricing");
|
|
515
|
+
log.step("npx cronus-ui theme set aurora --mode dark");
|
|
516
|
+
log.step("npx cronus-ui upgrade --all --dry-run");
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Plan the add-page (validating everything) and return the files it WOULD write
|
|
520
|
+
* and blocks it WOULD install, without touching the filesystem. Reuses the same
|
|
521
|
+
* reload + synthesize + plan path as {@link addPage}.
|
|
522
|
+
*/
|
|
523
|
+
export async function previewAddPage(options) {
|
|
524
|
+
const { targetDir } = options;
|
|
525
|
+
if (!hasConfig(targetDir)) {
|
|
526
|
+
throw new Error("No cronus-ui.json found. Run `cronus-ui init` first.");
|
|
527
|
+
}
|
|
528
|
+
const config = await readConfig(targetDir);
|
|
529
|
+
const { name: appName, record: composedRecord } = pickComposedApp(config, options.app);
|
|
530
|
+
const overwrite = options.overwrite ?? false;
|
|
531
|
+
const base = await reloadManifest(appName, options.manifestPath, composedRecord);
|
|
532
|
+
const firstChrome = Object.keys(base.manifest.chrome)[0];
|
|
533
|
+
const chromeGroup = options.chrome ?? firstChrome;
|
|
534
|
+
if (chromeGroup === undefined)
|
|
535
|
+
throw new Error(`App "${appName}" defines no chrome groups.`);
|
|
536
|
+
const newPage = {
|
|
537
|
+
route: options.route,
|
|
538
|
+
title: options.title ?? defaultTitle(options.route),
|
|
539
|
+
...(options.nav !== undefined ? { nav: options.nav } : {}),
|
|
540
|
+
chrome: chromeGroup,
|
|
541
|
+
blocks: options.blocks,
|
|
542
|
+
};
|
|
543
|
+
const { manifest, alreadyPresent } = synthesizeManifest(base, composedRecord, newPage, overwrite);
|
|
544
|
+
if (alreadyPresent && !overwrite) {
|
|
545
|
+
throw new Error(`Route "${options.route}" is already a page of app "${appName}" — pass --overwrite to replace it.`);
|
|
546
|
+
}
|
|
547
|
+
const registry = new Registry(options.registry ?? config.registry);
|
|
548
|
+
const meta = await readComposeMeta(registry);
|
|
549
|
+
if (meta === null) {
|
|
550
|
+
throw new Error("This registry does not ship a meta.json sidecar — add-page needs it (upgrade to v0.4.0+).");
|
|
551
|
+
}
|
|
552
|
+
const index = await registry.index();
|
|
553
|
+
const chromeSources = await readChromeSources(manifest, registry);
|
|
554
|
+
const choices = {
|
|
555
|
+
brand: composedRecord.choices.brand,
|
|
556
|
+
...(composedRecord.choices.seed !== undefined ? { seed: composedRecord.choices.seed } : {}),
|
|
557
|
+
// Mirror addPage: the new page's inline variant refs win over recorded ones.
|
|
558
|
+
variants: replanVariants(composedRecord.choices.variants, newPage.blocks),
|
|
559
|
+
};
|
|
560
|
+
const plan = buildComposePlan(manifest, choices, index, meta, chromeSources);
|
|
561
|
+
const planPage = plan.pages.find((p) => p.route === options.route);
|
|
562
|
+
if (planPage === undefined)
|
|
563
|
+
throw new Error(`Internal: planned page "${options.route}" not found.`);
|
|
564
|
+
const planChrome = plan.chromes.find((c) => c.group === chromeGroup);
|
|
565
|
+
// Predict EXACTLY what addPage writes, mirroring its apply path so a dry-run is
|
|
566
|
+
// trustworthy: the page.tsx, then (for a brand-new group) the chrome block(s)
|
|
567
|
+
// it installs + the SiteNav/SiteFooter/AppShellNav wrappers + the layout, and
|
|
568
|
+
// the customized chrome block(s) when `--nav` grows an existing group's nav.
|
|
569
|
+
const wouldWrite = [pagePath(planPage)];
|
|
570
|
+
const wouldInstallSet = new Set();
|
|
571
|
+
if (planChrome !== undefined) {
|
|
572
|
+
const newGroup = isNewChromeGroup(planChrome, targetDir);
|
|
573
|
+
const groupSlugs = chromeSlugsOfGroup(planChrome);
|
|
574
|
+
// Chrome block install (only for a brand-new group's not-yet-installed slugs).
|
|
575
|
+
if (newGroup) {
|
|
576
|
+
for (const slug of groupSlugs) {
|
|
577
|
+
if (config.installed?.[slug] === undefined)
|
|
578
|
+
wouldInstallSet.add(slug);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Chrome block customization (nav data-slot + brand): apply rewrites the block
|
|
582
|
+
// when `--nav` OR the group is new, per-slug only if the block will be on disk
|
|
583
|
+
// (new group installs it above; an existing group must already have it).
|
|
584
|
+
if (options.nav !== undefined || newGroup) {
|
|
585
|
+
for (const slug of groupSlugs) {
|
|
586
|
+
const rel = `${config.paths.blocks}/${slug}.tsx`;
|
|
587
|
+
const onDisk = existsSync(resolveSafeDest(targetDir, ".", rel));
|
|
588
|
+
if (newGroup || onDisk)
|
|
589
|
+
wouldWrite.push(rel);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// Layout + chrome wrappers: only a brand-new group scaffolds them, each gated
|
|
593
|
+
// on "not already present" — exactly mirroring ensureChromeScaffold.
|
|
594
|
+
if (newGroup) {
|
|
595
|
+
const predictWrapper = (name) => {
|
|
596
|
+
const rel = chromeWrapperPath(config, name);
|
|
597
|
+
if (!existsSync(resolveSafeDest(targetDir, ".", rel)))
|
|
598
|
+
wouldWrite.push(rel);
|
|
599
|
+
};
|
|
600
|
+
if (planChrome.navbar !== undefined && plan.navbarExportName !== undefined) {
|
|
601
|
+
predictWrapper("site-nav");
|
|
602
|
+
}
|
|
603
|
+
if (planChrome.footer !== undefined && plan.footerExportName !== undefined) {
|
|
604
|
+
predictWrapper("site-footer");
|
|
605
|
+
}
|
|
606
|
+
if (planChrome.block !== undefined && plan.shellExportName !== undefined) {
|
|
607
|
+
predictWrapper("app-shell");
|
|
608
|
+
}
|
|
609
|
+
const layoutRel = layoutPath(planChrome);
|
|
610
|
+
if (!existsSync(resolveSafeDest(targetDir, ".", layoutRel)))
|
|
611
|
+
wouldWrite.push(layoutRel);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
for (const item of planPage.blocks.map((b) => b.item)) {
|
|
615
|
+
if (config.installed?.[item] === undefined)
|
|
616
|
+
wouldInstallSet.add(item);
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
appName,
|
|
620
|
+
route: options.route,
|
|
621
|
+
wouldWrite: [...new Set(wouldWrite)],
|
|
622
|
+
wouldInstall: [...wouldInstallSet],
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/** Print a plan/manifest error's aggregated list, or a plain message. */
|
|
626
|
+
function reportError(err) {
|
|
627
|
+
if (err instanceof ComposePlanError) {
|
|
628
|
+
log.err("Add-page plan is invalid:");
|
|
629
|
+
for (const e of err.errors)
|
|
630
|
+
log.step(e);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const maybe = err;
|
|
634
|
+
if (Array.isArray(maybe.errors)) {
|
|
635
|
+
log.err("App manifest is invalid:");
|
|
636
|
+
for (const e of maybe.errors)
|
|
637
|
+
log.step(e);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
log.err(err.message);
|
|
641
|
+
}
|
|
642
|
+
//# sourceMappingURL=add-page.js.map
|