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,138 @@
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 { existsSync } from "node:fs";
10
+ import { readdir, readFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { resolveSafeDest } from "../utils.js";
13
+ import { manifestFingerprint } from "./manifest.js";
14
+ import { loadManifestFile, loadTemplate } from "./templates.js";
15
+ /** Base-snapshot directory for an app's generated bytes (F4 merge base). */
16
+ export function baseSnapshotDir(appKey) {
17
+ return join(".cronus-ui", "base", appKey);
18
+ }
19
+ /** Keys to try when reading a snapshot: composed key first, then legacy appName. */
20
+ function snapshotReadKeys(composedKey, appName) {
21
+ if (appName.length === 0 || appName === composedKey)
22
+ return [composedKey];
23
+ return [composedKey, appName];
24
+ }
25
+ /**
26
+ * Absolute dest of a snapshot file for a NEW write. Always the composed key —
27
+ * never the legacy package-name dir.
28
+ */
29
+ export function baseSnapshotDest(cwd, composedKey, relPath) {
30
+ return resolveSafeDest(cwd, baseSnapshotDir(composedKey), relPath);
31
+ }
32
+ /**
33
+ * Read one snapshot file. Prefers `.cronus-ui/base/<composedKey>/<rel>`; if that
34
+ * file is missing, falls back to `.cronus-ui/base/<appName>/<rel>` (legacy
35
+ * compose). Returns undefined when neither exists.
36
+ */
37
+ export async function readBaseSnapshot(cwd, composedKey, appName, relPath) {
38
+ for (const key of snapshotReadKeys(composedKey, appName)) {
39
+ let dest;
40
+ try {
41
+ dest = resolveSafeDest(cwd, baseSnapshotDir(key), relPath);
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ if (existsSync(dest))
47
+ return readFile(dest, "utf8");
48
+ }
49
+ return undefined;
50
+ }
51
+ /** Recursively list files under `dir` as paths relative to `dir`. */
52
+ async function walkRelFiles(dir, prefix = "") {
53
+ const out = [];
54
+ try {
55
+ const entries = await readdir(dir, { withFileTypes: true, encoding: "utf8" });
56
+ for (const entry of entries) {
57
+ const rel = prefix === "" ? entry.name : join(prefix, entry.name);
58
+ if (entry.isDirectory()) {
59
+ out.push(...(await walkRelFiles(join(dir, entry.name), rel)));
60
+ }
61
+ else if (entry.isFile()) {
62
+ out.push(rel);
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ return out;
68
+ }
69
+ return out;
70
+ }
71
+ /**
72
+ * Rel paths present in the snapshot tree(s). Unions the composed-key dir with
73
+ * the legacy appName dir when both exist, so a mixed pre/post-fix project
74
+ * still sees every snapshotted file.
75
+ */
76
+ export async function listBaseSnapshotRels(cwd, composedKey, appName) {
77
+ const rels = new Set();
78
+ for (const key of snapshotReadKeys(composedKey, appName)) {
79
+ const dir = join(cwd, baseSnapshotDir(key));
80
+ if (!existsSync(dir))
81
+ continue;
82
+ for (const rel of await walkRelFiles(dir))
83
+ rels.add(rel);
84
+ }
85
+ return [...rels];
86
+ }
87
+ /**
88
+ * Reload the app's manifest: an explicit `--manifest` file wins, else the bundled
89
+ * template whose name equals the composed key. A `--manifest`-composed app whose
90
+ * name is not a bundled template must re-supply `--manifest` (its manifest is not
91
+ * recoverable from `composed{}` alone) — we fail loud with that hint.
92
+ *
93
+ * PROVENANCE GUARD: the bundled fallback is keyed only on the app NAME, but a
94
+ * `--manifest`-composed app is keyed by the manifest's own `name` field, which can
95
+ * COLLIDE with a bundled template name (store/landing/saas). In that case
96
+ * `loadTemplate` silently returns the WRONG (bundled) manifest, and a re-plan
97
+ * would then drop every composed route/chrome group that the bundled template does
98
+ * not declare — silently corrupting the nav + composed record. So we verify the
99
+ * reloaded bundled template's content fingerprint against the compose-time
100
+ * `manifestHash` provenance recorded in `composed{}` and fail loud on a mismatch,
101
+ * demanding `--manifest`. A legacy record (composed before provenance existed) has
102
+ * no hash to check, so that case falls back to the prior lenient behavior.
103
+ */
104
+ export async function reloadManifest(appName, manifestPath, composed) {
105
+ if (manifestPath !== undefined)
106
+ return loadManifestFile(manifestPath);
107
+ let bundled;
108
+ try {
109
+ bundled = await loadTemplate(appName);
110
+ }
111
+ catch (err) {
112
+ throw new Error(`Cannot reload the manifest for composed app "${appName}": it is not a bundled template. ` +
113
+ `Pass --manifest <file> pointing at the manifest it was composed from. (${err.message})`);
114
+ }
115
+ // Verify provenance: the recorded hash (from compose) must equal the reloaded
116
+ // bundled template's fingerprint. A mismatch means this app was composed from a
117
+ // DIFFERENT manifest whose name collides with the bundled template.
118
+ const recorded = composed.manifestHash;
119
+ if (recorded !== undefined && recorded !== manifestFingerprint(bundled)) {
120
+ throw new Error(`The bundled template "${appName}" is not the manifest app "${appName}" was composed from ` +
121
+ `(provenance hash mismatch). This app was composed from a custom --manifest whose name collides ` +
122
+ `with a bundled template. Re-run with --manifest <file> pointing at that manifest.`);
123
+ }
124
+ return bundled;
125
+ }
126
+ /**
127
+ * Restrict a reloaded manifest to the pages this app actually composed
128
+ * (`composed.choices.pages`, preserving manifest order). Add-page grafts that
129
+ * are not in the bundled/reloaded template are omitted from the synthetic
130
+ * manifest (they have no upstream render) — callers must keep them in
131
+ * `choices.pages` and on disk.
132
+ */
133
+ export function filterManifestToComposedPages(base, composed) {
134
+ const composedRoutes = new Set(composed.choices.pages);
135
+ const pages = base.manifest.pages.filter((p) => composedRoutes.has(p.route));
136
+ return { ...base, manifest: { ...base.manifest, pages } };
137
+ }
138
+ //# sourceMappingURL=reload.js.map
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The PURE compose renderer. Given a validated plan it emits the generated files:
3
+ * one `page.tsx` per route, one route-group `layout.tsx` per chrome group, and the
4
+ * thin `components/chrome/site-nav.tsx` / `site-footer.tsx` wrappers.
5
+ *
6
+ * GOLDEN RULE: a generated page is ONLY `imports of installed blocks + a <main>
7
+ * stacking them`. This module never emits UI JSX beyond that wrapper (and the
8
+ * chrome layout wrapper, itself just block wrappers). Every visible pixel comes
9
+ * from a registry item.
10
+ *
11
+ * DETERMINISM: a pure function of `(plan, config)` — no `Date`/random, stable
12
+ * ordering — so `registry:check`-style byte-equality and the F4 3-way base hold.
13
+ */
14
+ import type { CronusUIConfig } from "../config.js";
15
+ import type { ComposePlan, PlanBlock, PlanChrome, PlanExtra, PlanPage } from "./plan.js";
16
+ /** A file the composer will write, relative to the project root. */
17
+ export interface GeneratedFile {
18
+ /** Project-relative path, e.g. "app/(site)/page.tsx". */
19
+ path: string;
20
+ content: string;
21
+ }
22
+ /** How the composer customizes one installed chrome block copy (nav data + brand). */
23
+ export interface ChromeRewrite {
24
+ /** The chrome block slug (e.g. "navbar"). */
25
+ slug: string;
26
+ /** Project-relative path of the installed block file to rewrite in place. */
27
+ file: string;
28
+ /** The rewritten source (data-slot + brand applied). */
29
+ content: string;
30
+ }
31
+ /** Everything the composer emits: generated files + the in-place chrome rewrites. */
32
+ export interface RenderResult {
33
+ files: GeneratedFile[];
34
+ chromeRewrites: ChromeRewrite[];
35
+ }
36
+ /** One nav entry derived from a manifest page carrying a `nav` label. */
37
+ interface NavLink {
38
+ label: string;
39
+ href: string;
40
+ }
41
+ /**
42
+ * The nav links derived from the plan's pages: every page that carries a `nav`
43
+ * label, in manifest order, linking to its route. Deterministic.
44
+ */
45
+ export declare function navLinksOf(plan: ComposePlan): NavLink[];
46
+ /**
47
+ * The nav links of the pages under one chrome group (every such page carrying a
48
+ * `nav` label), in manifest order. The app-shell sidebar nav is scoped to its
49
+ * own (app) group's pages — a shell sidebar lists the app sections, not a bare
50
+ * /login page. Falls back to `navLinksOf` shape but filtered by group.
51
+ */
52
+ export declare function navLinksForGroup(plan: ComposePlan, group: string): NavLink[];
53
+ /**
54
+ * Rewrite one installed chrome block copy: inject the real nav links into its
55
+ * data-slot and replace its brand literal(s) with the app brand. `blockSource` is
56
+ * the exact on-disk copy (post-`add`), so the markers/literals are present or the
57
+ * data-slot helpers throw (fail-loud). Pure.
58
+ */
59
+ export declare function rewriteChromeBlock(slug: string, blockSource: string, plan: ComposePlan): string;
60
+ /**
61
+ * The blocks-alias import base for a block. The installed file is named after the
62
+ * item with the `--` variant separator collapsed to a single dash
63
+ * (`login--split` → `login-split.tsx`), so the import must match that file
64
+ * basename: the bare `<slug>` for the default variant, or `<slug>-<variant>` for
65
+ * a non-default one. Built from slug+variant (not by munging the item name) so
66
+ * it always agrees with the registry's `<slug>-<variantId>.tsx` file name.
67
+ */
68
+ export declare function blockImportBase(block: {
69
+ slug: string;
70
+ variant?: string;
71
+ }): string;
72
+ /** Render a single page.tsx: imports of its blocks + a <main> stacking them. */
73
+ export declare function renderPage(page: PlanPage, config: CronusUIConfig): string;
74
+ /** Path of a page.tsx under its chrome route group. */
75
+ export declare function pagePath(page: PlanPage): string;
76
+ /**
77
+ * Render a route-group layout for a chrome group:
78
+ * - site = navbar + footer thin wrappers around a flex column;
79
+ * - shell = the AppShellNav thin wrapper (sidebar + header) around {children};
80
+ * - bare = a passthrough layout (centered pages own their own frame).
81
+ */
82
+ export declare function renderLayout(chrome: PlanChrome, config: CronusUIConfig): string;
83
+ /** Path of a chrome route-group layout. */
84
+ export declare function layoutPath(chrome: PlanChrome): string;
85
+ /**
86
+ * Render a thin chrome wrapper: a named re-export of the installed block's default
87
+ * export, so the layout imports a stable `SiteNav`/`SiteFooter` while the visual
88
+ * source stays the (customized) installed block. GOLDEN-RULE compliant: pure
89
+ * re-export, no new UI.
90
+ */
91
+ export declare function renderChromeWrapper(exportAs: string, blockSlug: string, blockExportName: string, config: CronusUIConfig): string;
92
+ /**
93
+ * Render the thin app-shell wrapper: a `children`-forwarding component that wraps
94
+ * the layout's {children} in the installed shell chrome block. Unlike SiteNav/
95
+ * SiteFooter (which render standalone furniture), the shell composes the page
96
+ * content, so this wrapper takes + forwards `children`. GOLDEN-RULE compliant:
97
+ * imports the installed block and forwards children — no new UI. Pure.
98
+ */
99
+ export declare function renderShellWrapper(exportAs: string, blockSlug: string, blockExportName: string, config: CronusUIConfig): string;
100
+ /** Path of a chrome wrapper file under the blocks path. */
101
+ export declare function chromeWrapperPath(config: CronusUIConfig, name: string): string;
102
+ /**
103
+ * Render a Next special-file wrapper page for an `extras` block (e.g.
104
+ * `app/not-found.tsx`). GOLDEN-RULE compliant: imports the installed page-kind
105
+ * block and centers it in a single `<main>` — no new UI. The component name is
106
+ * PascalCase(key)Page (e.g. "not-found" → NotFoundPage). Pure.
107
+ */
108
+ export declare function renderExtra(extra: PlanExtra, config: CronusUIConfig): string;
109
+ /**
110
+ * Render the whole plan into files. Deterministic ordering: chrome rewrites by
111
+ * slug, then wrappers, then layouts (by group), then pages (manifest order),
112
+ * then Next special-file extras (by key). The caller writes them; nothing here
113
+ * touches the filesystem.
114
+ *
115
+ * NOTE: the app brand reaches every visible surface via the brandTokens
116
+ * literal-replacement path ({@link rewriteChromeBlock} → replaceBrandLiteral in
117
+ * the chrome navbar/footer/hero) — NOT via a generated `lib/brand.ts`. The demo
118
+ * libs (`demo-store`/`demo-saas`) carry their own standalone `BRAND` default for
119
+ * the storefront/dashboard demo name; compose does not override it.
120
+ */
121
+ export declare function renderPlan(plan: ComposePlan, config: CronusUIConfig): RenderResult;
122
+ export type { PlanBlock };
123
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1,404 @@
1
+ /**
2
+ * The PURE compose renderer. Given a validated plan it emits the generated files:
3
+ * one `page.tsx` per route, one route-group `layout.tsx` per chrome group, and the
4
+ * thin `components/chrome/site-nav.tsx` / `site-footer.tsx` wrappers.
5
+ *
6
+ * GOLDEN RULE: a generated page is ONLY `imports of installed blocks + a <main>
7
+ * stacking them`. This module never emits UI JSX beyond that wrapper (and the
8
+ * chrome layout wrapper, itself just block wrappers). Every visible pixel comes
9
+ * from a registry item.
10
+ *
11
+ * DETERMINISM: a pure function of `(plan, config)` — no `Date`/random, stable
12
+ * ordering — so `registry:check`-style byte-equality and the F4 3-way base hold.
13
+ */
14
+ import { replaceBrandLiteral, replaceDataSlot } from "./data-slots.js";
15
+ /**
16
+ * Turn an App Router route into a page-directory path under a route group. "/" is
17
+ * the group root (empty dir); "/products/[id]" → "products/[id]". Dynamic segments
18
+ * are preserved verbatim (already validated in plan.ts).
19
+ */
20
+ function routeToDir(route) {
21
+ const trimmed = route.replace(/^\/+/, "").replace(/\/+$/, "");
22
+ return trimmed;
23
+ }
24
+ /** Derive a PascalCase component name for a generated page from its route. */
25
+ function pageComponentName(route) {
26
+ const dir = routeToDir(route);
27
+ if (dir === "")
28
+ return "HomePage";
29
+ const parts = dir
30
+ .split("/")
31
+ .map((seg) => seg.replace(/\[(\.\.\.)?([^\]]+)\]/g, "$2")) // [id] / [...slug] → id / slug
32
+ .flatMap((seg) => seg.split(/[-_]/))
33
+ .filter((s) => s.length > 0)
34
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1));
35
+ return `${parts.join("")}Page`;
36
+ }
37
+ /** Serialize a JS string literal deterministically (double quotes, escaped). */
38
+ function jsString(value) {
39
+ return JSON.stringify(value);
40
+ }
41
+ /** Serialize nav links to a `const NAME = [...]` body (2-space indent, stable). */
42
+ function serializeNavLinks(constName, links) {
43
+ if (links.length === 0)
44
+ return `const ${constName} = [];`;
45
+ const rows = links
46
+ .map((l) => ` { label: ${jsString(l.label)}, href: ${jsString(l.href)} },`)
47
+ .join("\n");
48
+ return `const ${constName} = [\n${rows}\n];`;
49
+ }
50
+ /** Serialize the footer columns const (a single "Navigation" column of the nav links). */
51
+ function serializeFooterColumns(constName, links) {
52
+ const inner = links
53
+ .map((l) => ` { label: ${jsString(l.label)}, href: ${jsString(l.href)} },`)
54
+ .join("\n");
55
+ const linksBlock = links.length === 0 ? "[]" : `[\n${inner}\n ]`;
56
+ return `const ${constName} = [\n {\n heading: "Navigation",\n links: ${linksBlock},\n },\n];`;
57
+ }
58
+ /**
59
+ * Serialize the app-shell sidebar nav const (a flat `{ label, href }[]`, same
60
+ * shape as the navbar links). The shell block maps over this const to render the
61
+ * SidebarMenu items, so the composer replaces its body from the (app) group's
62
+ * nav pages. Kept as a plain `const NAME = [...]` (no `as const`) so the emitted
63
+ * data-slot matches the shipped code literal's const exactly.
64
+ */
65
+ function serializeAppNav(constName, links) {
66
+ return serializeNavLinks(constName, links);
67
+ }
68
+ /**
69
+ * The nav links derived from the plan's pages: every page that carries a `nav`
70
+ * label, in manifest order, linking to its route. Deterministic.
71
+ */
72
+ export function navLinksOf(plan) {
73
+ const links = [];
74
+ for (const page of plan.pages) {
75
+ if (page.nav !== undefined)
76
+ links.push({ label: page.nav, href: page.route });
77
+ }
78
+ return links;
79
+ }
80
+ /**
81
+ * The nav links of the pages under one chrome group (every such page carrying a
82
+ * `nav` label), in manifest order. The app-shell sidebar nav is scoped to its
83
+ * own (app) group's pages — a shell sidebar lists the app sections, not a bare
84
+ * /login page. Falls back to `navLinksOf` shape but filtered by group.
85
+ */
86
+ export function navLinksForGroup(plan, group) {
87
+ const links = [];
88
+ for (const page of plan.pages) {
89
+ if (page.chrome === group && page.nav !== undefined) {
90
+ links.push({ label: page.nav, href: page.route });
91
+ }
92
+ }
93
+ return links;
94
+ }
95
+ /**
96
+ * The data-const name declared inside a chrome block's data-slot. Kept as an
97
+ * explicit table (not inferred) so the serialized replacement matches the shipped
98
+ * const exactly — the two chrome families F1 supports.
99
+ */
100
+ const CHROME_SLOT_CONST = {
101
+ "navbar-links": "NAVBAR_LINKS",
102
+ "footer-links": "FOOTER_COLUMNS",
103
+ "app-nav": "APP_NAV",
104
+ };
105
+ /**
106
+ * Rewrite one installed chrome block copy: inject the real nav links into its
107
+ * data-slot and replace its brand literal(s) with the app brand. `blockSource` is
108
+ * the exact on-disk copy (post-`add`), so the markers/literals are present or the
109
+ * data-slot helpers throw (fail-loud). Pure.
110
+ */
111
+ export function rewriteChromeBlock(slug, blockSource, plan) {
112
+ const links = navLinksOf(plan);
113
+ // The (app)-group this chrome block serves, if it is the shell — its sidebar
114
+ // nav is scoped to that group's pages. A navbar/footer slug has no group here.
115
+ const shellGroup = plan.chromes.find((c) => c.block === slug)?.group;
116
+ let out = blockSource;
117
+ for (const slot of plan.dataSlotsBySlug[slug] ?? []) {
118
+ const constName = CHROME_SLOT_CONST[slot];
119
+ if (constName === undefined) {
120
+ // A declared slot with no known serializer is a build/table drift — the
121
+ // markers exist (gated) but we have no data to fill them, so fail loud.
122
+ throw new Error(`chrome block "${slug}": no serializer for data-slot "${slot}"`);
123
+ }
124
+ let body;
125
+ if (constName === "FOOTER_COLUMNS") {
126
+ body = serializeFooterColumns(constName, links);
127
+ }
128
+ else if (constName === "APP_NAV") {
129
+ // Scope the sidebar nav to the shell's own group pages when known.
130
+ const groupLinks = shellGroup !== undefined ? navLinksForGroup(plan, shellGroup) : links;
131
+ body = serializeAppNav(constName, groupLinks);
132
+ }
133
+ else {
134
+ body = serializeNavLinks(constName, links);
135
+ }
136
+ out = replaceDataSlot(out, slot, body);
137
+ }
138
+ for (const brand of plan.brandTokensBySlug[slug] ?? []) {
139
+ out = replaceBrandLiteral(out, brand.literal, plan.choices.brand);
140
+ }
141
+ return out;
142
+ }
143
+ /**
144
+ * The blocks-alias import base for a block. The installed file is named after the
145
+ * item with the `--` variant separator collapsed to a single dash
146
+ * (`login--split` → `login-split.tsx`), so the import must match that file
147
+ * basename: the bare `<slug>` for the default variant, or `<slug>-<variant>` for
148
+ * a non-default one. Built from slug+variant (not by munging the item name) so
149
+ * it always agrees with the registry's `<slug>-<variantId>.tsx` file name.
150
+ */
151
+ export function blockImportBase(block) {
152
+ return block.variant === undefined ? block.slug : `${block.slug}-${block.variant}`;
153
+ }
154
+ /** Import specifier for a block via the consumer's blocks alias. */
155
+ function blockImport(config, importBase) {
156
+ return `${config.aliases.blocks}/${importBase}`;
157
+ }
158
+ /** Render a single page.tsx: imports of its blocks + a <main> stacking them. */
159
+ export function renderPage(page, config) {
160
+ // De-dupe imports by exportName (a page may legitimately repeat a section, but
161
+ // it must be imported once); keep first-seen order for determinism.
162
+ const imports = [];
163
+ const seen = new Set();
164
+ for (const block of page.blocks) {
165
+ if (seen.has(block.exportName))
166
+ continue;
167
+ seen.add(block.exportName);
168
+ imports.push(`import { ${block.exportName} } from ${jsString(blockImport(config, blockImportBase(block)))};`);
169
+ }
170
+ const importLines = imports.join("\n");
171
+ const componentName = pageComponentName(page.route);
172
+ const stack = page.blocks.map((b) => ` <${b.exportName} />`).join("\n");
173
+ // Page-kind blocks are full-page surfaces; when a route renders a single page
174
+ // block, center it. Otherwise stack sections in a plain column.
175
+ const single = page.blocks.length === 1 && page.blocks[0]?.kind === "page";
176
+ const mainClass = single
177
+ ? "flex min-h-svh flex-col items-center justify-center"
178
+ : "flex min-h-svh flex-col";
179
+ return [
180
+ importLines,
181
+ "",
182
+ `export const metadata = { title: ${jsString(page.title)} };`,
183
+ "",
184
+ `export default function ${componentName}() {`,
185
+ " return (",
186
+ ` <main className=${jsString(mainClass)}>`,
187
+ stack,
188
+ " </main>",
189
+ " );",
190
+ "}",
191
+ "",
192
+ ].join("\n");
193
+ }
194
+ /** Path of a page.tsx under its chrome route group. */
195
+ export function pagePath(page) {
196
+ const dir = routeToDir(page.route);
197
+ const group = `(${page.chrome})`;
198
+ return dir === "" ? `app/${group}/page.tsx` : `app/${group}/${dir}/page.tsx`;
199
+ }
200
+ /** Import path from a route-group layout to a chrome wrapper via the blocks alias. */
201
+ function chromeWrapperImport(config, name) {
202
+ // Wrappers live under the blocks path in a `chrome/` subdir; import via the
203
+ // blocks alias so the consumer's tsconfig path mapping resolves them.
204
+ return `${config.aliases.blocks}/chrome/${name}`;
205
+ }
206
+ /**
207
+ * Render a route-group layout for a chrome group:
208
+ * - site = navbar + footer thin wrappers around a flex column;
209
+ * - shell = the AppShellNav thin wrapper (sidebar + header) around {children};
210
+ * - bare = a passthrough layout (centered pages own their own frame).
211
+ */
212
+ export function renderLayout(chrome, config) {
213
+ const hasNav = chrome.navbar !== undefined;
214
+ const hasFooter = chrome.footer !== undefined;
215
+ const hasShell = chrome.block !== undefined;
216
+ if (hasShell) {
217
+ // App-shell group: the whole page frame is the shell block (sidebar + header
218
+ // wrapping {children}). The layout is a thin wrapper import + a single
219
+ // <AppShellNav> — no invented UI, mirroring the (site) SiteNav/SiteFooter
220
+ // pattern. The generated page still owns the one <main> inside {children}.
221
+ return [
222
+ `import type { ReactNode } from "react";`,
223
+ `import { AppShellNav } from ${jsString(chromeWrapperImport(config, "app-shell"))};`,
224
+ "",
225
+ `export default function ${layoutComponentName(chrome.group)}({ children }: { children: ReactNode }) {`,
226
+ " return <AppShellNav>{children}</AppShellNav>;",
227
+ "}",
228
+ "",
229
+ ].join("\n");
230
+ }
231
+ if (!hasNav && !hasFooter) {
232
+ // Bare/centered group: no chrome furniture, just a passthrough layout so the
233
+ // route group is a real segment (keeps auth/checkout off the site chrome).
234
+ return [
235
+ `import type { ReactNode } from "react";`,
236
+ "",
237
+ `export default function ${layoutComponentName(chrome.group)}({ children }: { children: ReactNode }) {`,
238
+ " return <>{children}</>;",
239
+ "}",
240
+ "",
241
+ ].join("\n");
242
+ }
243
+ const imports = [`import type { ReactNode } from "react";`];
244
+ if (hasNav)
245
+ imports.push(`import { SiteNav } from ${jsString(chromeWrapperImport(config, "site-nav"))};`);
246
+ if (hasFooter)
247
+ imports.push(`import { SiteFooter } from ${jsString(chromeWrapperImport(config, "site-footer"))};`);
248
+ const body = [];
249
+ body.push(` <div className="flex min-h-svh flex-col">`);
250
+ if (hasNav)
251
+ body.push(" <SiteNav />");
252
+ body.push(` <div className="flex-1">{children}</div>`);
253
+ if (hasFooter)
254
+ body.push(" <SiteFooter />");
255
+ body.push(" </div>");
256
+ return [
257
+ imports.join("\n"),
258
+ "",
259
+ `export default function ${layoutComponentName(chrome.group)}({ children }: { children: ReactNode }) {`,
260
+ " return (",
261
+ body.join("\n"),
262
+ " );",
263
+ "}",
264
+ "",
265
+ ].join("\n");
266
+ }
267
+ /** PascalCase layout component name for a chrome group. */
268
+ function layoutComponentName(group) {
269
+ const pascal = group
270
+ .split(/[-_]/)
271
+ .filter((s) => s.length > 0)
272
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
273
+ .join("");
274
+ return `${pascal}Layout`;
275
+ }
276
+ /** Path of a chrome route-group layout. */
277
+ export function layoutPath(chrome) {
278
+ return `app/(${chrome.group})/layout.tsx`;
279
+ }
280
+ /**
281
+ * Render a thin chrome wrapper: a named re-export of the installed block's default
282
+ * export, so the layout imports a stable `SiteNav`/`SiteFooter` while the visual
283
+ * source stays the (customized) installed block. GOLDEN-RULE compliant: pure
284
+ * re-export, no new UI.
285
+ */
286
+ export function renderChromeWrapper(exportAs, blockSlug, blockExportName, config) {
287
+ return [
288
+ `import { ${blockExportName} } from ${jsString(blockImport(config, blockSlug))};`,
289
+ "",
290
+ `export function ${exportAs}() {`,
291
+ ` return <${blockExportName} />;`,
292
+ "}",
293
+ "",
294
+ ].join("\n");
295
+ }
296
+ /**
297
+ * Render the thin app-shell wrapper: a `children`-forwarding component that wraps
298
+ * the layout's {children} in the installed shell chrome block. Unlike SiteNav/
299
+ * SiteFooter (which render standalone furniture), the shell composes the page
300
+ * content, so this wrapper takes + forwards `children`. GOLDEN-RULE compliant:
301
+ * imports the installed block and forwards children — no new UI. Pure.
302
+ */
303
+ export function renderShellWrapper(exportAs, blockSlug, blockExportName, config) {
304
+ return [
305
+ `import type { ReactNode } from "react";`,
306
+ `import { ${blockExportName} } from ${jsString(blockImport(config, blockSlug))};`,
307
+ "",
308
+ `export function ${exportAs}({ children }: { children: ReactNode }) {`,
309
+ ` return <${blockExportName}>{children}</${blockExportName}>;`,
310
+ "}",
311
+ "",
312
+ ].join("\n");
313
+ }
314
+ /** Path of a chrome wrapper file under the blocks path. */
315
+ export function chromeWrapperPath(config, name) {
316
+ return `${config.paths.blocks}/chrome/${name}.tsx`;
317
+ }
318
+ /**
319
+ * Render a Next special-file wrapper page for an `extras` block (e.g.
320
+ * `app/not-found.tsx`). GOLDEN-RULE compliant: imports the installed page-kind
321
+ * block and centers it in a single `<main>` — no new UI. The component name is
322
+ * PascalCase(key)Page (e.g. "not-found" → NotFoundPage). Pure.
323
+ */
324
+ export function renderExtra(extra, config) {
325
+ const componentName = `${extra.key
326
+ .split(/[-_]/)
327
+ .filter((s) => s.length > 0)
328
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
329
+ .join("")}Page`;
330
+ return [
331
+ `import { ${extra.exportName} } from ${jsString(blockImport(config, extra.slug))};`,
332
+ "",
333
+ `export default function ${componentName}() {`,
334
+ " return (",
335
+ ` <main className="flex min-h-svh flex-col items-center justify-center">`,
336
+ ` <${extra.exportName} />`,
337
+ " </main>",
338
+ " );",
339
+ "}",
340
+ "",
341
+ ].join("\n");
342
+ }
343
+ /**
344
+ * Render the whole plan into files. Deterministic ordering: chrome rewrites by
345
+ * slug, then wrappers, then layouts (by group), then pages (manifest order),
346
+ * then Next special-file extras (by key). The caller writes them; nothing here
347
+ * touches the filesystem.
348
+ *
349
+ * NOTE: the app brand reaches every visible surface via the brandTokens
350
+ * literal-replacement path ({@link rewriteChromeBlock} → replaceBrandLiteral in
351
+ * the chrome navbar/footer/hero) — NOT via a generated `lib/brand.ts`. The demo
352
+ * libs (`demo-store`/`demo-saas`) carry their own standalone `BRAND` default for
353
+ * the storefront/dashboard demo name; compose does not override it.
354
+ */
355
+ export function renderPlan(plan, config) {
356
+ const files = [];
357
+ const chromeRewrites = [];
358
+ // 1. Chrome block rewrites (in-place customization of installed copies).
359
+ for (const slug of plan.chromeSlugs) {
360
+ const source = plan.chromeSources[slug];
361
+ if (source === undefined)
362
+ continue;
363
+ chromeRewrites.push({
364
+ slug,
365
+ file: `${config.paths.blocks}/${slug}.tsx`,
366
+ content: rewriteChromeBlock(slug, source, plan),
367
+ });
368
+ }
369
+ // 2. Thin chrome wrappers (SiteNav / SiteFooter), only for chrome actually used.
370
+ if (plan.usesNavbar && plan.navbarExportName !== undefined && plan.navbarSlug !== undefined) {
371
+ files.push({
372
+ path: chromeWrapperPath(config, "site-nav"),
373
+ content: renderChromeWrapper("SiteNav", plan.navbarSlug, plan.navbarExportName, config),
374
+ });
375
+ }
376
+ if (plan.usesFooter && plan.footerExportName !== undefined && plan.footerSlug !== undefined) {
377
+ files.push({
378
+ path: chromeWrapperPath(config, "site-footer"),
379
+ content: renderChromeWrapper("SiteFooter", plan.footerSlug, plan.footerExportName, config),
380
+ });
381
+ }
382
+ // The app-shell wrapper (AppShellNav): a children-forwarding thin wrapper the
383
+ // (app)-group layout imports, only when a group uses the shell.
384
+ if (plan.usesShell && plan.shellExportName !== undefined && plan.shellSlug !== undefined) {
385
+ files.push({
386
+ path: chromeWrapperPath(config, "app-shell"),
387
+ content: renderShellWrapper("AppShellNav", plan.shellSlug, plan.shellExportName, config),
388
+ });
389
+ }
390
+ // 3. Route-group layouts, one per chrome group used (stable by group name).
391
+ for (const chrome of plan.chromes) {
392
+ files.push({ path: layoutPath(chrome), content: renderLayout(chrome, config) });
393
+ }
394
+ // 4. Pages, in manifest order.
395
+ for (const page of plan.pages) {
396
+ files.push({ path: pagePath(page), content: renderPage(page, config) });
397
+ }
398
+ // 5. Next special-file wrappers from `extras` (sorted by key in the plan).
399
+ for (const extra of plan.extras) {
400
+ files.push({ path: extra.file, content: renderExtra(extra, config) });
401
+ }
402
+ return { files, chromeRewrites };
403
+ }
404
+ //# sourceMappingURL=render.js.map