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,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
|