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,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchored replacement of a block's `@cronus:data <name>` data const and its brand
|
|
3
|
+
* literal. Both mechanisms are marker/literal driven and FAIL LOUD when the anchor
|
|
4
|
+
* is absent — the composer must never silently target a missing slot or brand
|
|
5
|
+
* (the same guarantee `registry:check` enforces at build time, re-checked here
|
|
6
|
+
* against the bytes actually on disk).
|
|
7
|
+
*
|
|
8
|
+
* The data-slot replacement operates on DATA CONSTS ONLY (never JSX): the region
|
|
9
|
+
* between `/* @cronus:data X *\/` and the FIRST following `/* @cronus:data-end *\/`
|
|
10
|
+
* is replaced wholesale with a freshly-serialized const, keeping the markers so a
|
|
11
|
+
* re-compose stays idempotent. The brand replacement, by contrast, targets JSX
|
|
12
|
+
* TEXT (the chrome wordmark/copyright), so its injected value is JSX-escaped —
|
|
13
|
+
* source-side literal matching alone does NOT make the destination context safe.
|
|
14
|
+
*/
|
|
15
|
+
/** The exact marker pair for a named data-slot (mirrors build-registry's `dataSlotMarkers`). */
|
|
16
|
+
export declare function dataSlotMarkers(name: string): {
|
|
17
|
+
open: string;
|
|
18
|
+
close: string;
|
|
19
|
+
};
|
|
20
|
+
/** Thrown when a required data-slot marker or brand literal is missing from a source. */
|
|
21
|
+
export declare class DataSlotError extends Error {
|
|
22
|
+
constructor(message: string);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Replace the body delimited by a named data-slot's markers with `replacement`
|
|
26
|
+
* (the full const declaration, e.g. `const NAVBAR_LINKS = [...];`). The markers
|
|
27
|
+
* themselves are preserved so the result stays re-composable. Throws
|
|
28
|
+
* {@link DataSlotError} when the opening marker is absent or ambiguous (appears
|
|
29
|
+
* more than once), or when no closing marker follows it — never a silent no-op.
|
|
30
|
+
*
|
|
31
|
+
* The close marker is GENERIC (`/* @cronus:data-end *\/`) and shared by every slot,
|
|
32
|
+
* so a block with two data-slots carries two close markers. We therefore pair the
|
|
33
|
+
* open marker with the FIRST close marker that follows it (its own region end),
|
|
34
|
+
* rather than rejecting any source in which the shared close marker repeats. This
|
|
35
|
+
* is deterministic and unambiguous because slot regions are flat siblings (never
|
|
36
|
+
* nested), so the first `data-end` after an `@cronus:data <name>` open always
|
|
37
|
+
* belongs to that same slot. The only ambiguity we still reject is a duplicated
|
|
38
|
+
* OPEN marker for this named slot (we would not know which region to rewrite).
|
|
39
|
+
*
|
|
40
|
+
* `source` — the on-disk block copy; `name` — the slot id; `replacement` — the
|
|
41
|
+
* new const source (no surrounding markers/newlines; they are re-added).
|
|
42
|
+
*/
|
|
43
|
+
export declare function replaceDataSlot(source: string, name: string, replacement: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* JSX-escape a value destined for a JSX **text** context (element children). The
|
|
46
|
+
* chrome brand literal (`Cronus`) lives inside JSX text nodes — the navbar/footer
|
|
47
|
+
* wordmark `<span>…</span>` and the footer copyright `<p>…</p>` — so a raw
|
|
48
|
+
* substitution of a brand containing `<`, `>`, `{` or `}` would emit invalid TSX
|
|
49
|
+
* (`<`/`>` are token starts) or an undefined JSX expression container (`{Store}`),
|
|
50
|
+
* breaking `next build`. Escaping here makes ANY brand safe regardless of source,
|
|
51
|
+
* which is stronger than validate-and-reject. `&` is escaped first so we never
|
|
52
|
+
* double-escape the entities we introduce. A brand with none of these characters
|
|
53
|
+
* is returned unchanged (no-op for safe brands like `Acme`).
|
|
54
|
+
*/
|
|
55
|
+
export declare function escapeJsxText(value: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Replace EVERY occurrence of a brand `literal` in the source with `brand`. Used
|
|
58
|
+
* for the chrome wordmark/copyright (`Cronus` → the app brand), which sit in JSX
|
|
59
|
+
* text nodes. Throws {@link DataSlotError} when the literal is absent so a
|
|
60
|
+
* renamed/removed anchor fails loud instead of leaving the placeholder brand.
|
|
61
|
+
*
|
|
62
|
+
* The `literal` MATCH is literal (not regex), so special characters in the search
|
|
63
|
+
* literal do not misbehave. The injected `brand`, however, lands in a JSX text
|
|
64
|
+
* context, so it is JSX-escaped ({@link escapeJsxText}) before substitution —
|
|
65
|
+
* otherwise a brand like `Tom > Jerry`, `Ben <Labs>`, `Acme {Store}` or `A & B`
|
|
66
|
+
* would emit invalid TSX / an undefined JSX expression and break `next build`.
|
|
67
|
+
* Escaping is a no-op for brands with no `& < > { }`, so safe brands are
|
|
68
|
+
* byte-identical to a raw substitution.
|
|
69
|
+
*/
|
|
70
|
+
export declare function replaceBrandLiteral(source: string, literal: string, brand: string): string;
|
|
71
|
+
//# sourceMappingURL=data-slots.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchored replacement of a block's `@cronus:data <name>` data const and its brand
|
|
3
|
+
* literal. Both mechanisms are marker/literal driven and FAIL LOUD when the anchor
|
|
4
|
+
* is absent — the composer must never silently target a missing slot or brand
|
|
5
|
+
* (the same guarantee `registry:check` enforces at build time, re-checked here
|
|
6
|
+
* against the bytes actually on disk).
|
|
7
|
+
*
|
|
8
|
+
* The data-slot replacement operates on DATA CONSTS ONLY (never JSX): the region
|
|
9
|
+
* between `/* @cronus:data X *\/` and the FIRST following `/* @cronus:data-end *\/`
|
|
10
|
+
* is replaced wholesale with a freshly-serialized const, keeping the markers so a
|
|
11
|
+
* re-compose stays idempotent. The brand replacement, by contrast, targets JSX
|
|
12
|
+
* TEXT (the chrome wordmark/copyright), so its injected value is JSX-escaped —
|
|
13
|
+
* source-side literal matching alone does NOT make the destination context safe.
|
|
14
|
+
*/
|
|
15
|
+
/** The exact marker pair for a named data-slot (mirrors build-registry's `dataSlotMarkers`). */
|
|
16
|
+
export function dataSlotMarkers(name) {
|
|
17
|
+
return { open: `/* @cronus:data ${name} */`, close: "/* @cronus:data-end */" };
|
|
18
|
+
}
|
|
19
|
+
/** Thrown when a required data-slot marker or brand literal is missing from a source. */
|
|
20
|
+
export class DataSlotError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "DataSlotError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Replace the body delimited by a named data-slot's markers with `replacement`
|
|
28
|
+
* (the full const declaration, e.g. `const NAVBAR_LINKS = [...];`). The markers
|
|
29
|
+
* themselves are preserved so the result stays re-composable. Throws
|
|
30
|
+
* {@link DataSlotError} when the opening marker is absent or ambiguous (appears
|
|
31
|
+
* more than once), or when no closing marker follows it — never a silent no-op.
|
|
32
|
+
*
|
|
33
|
+
* The close marker is GENERIC (`/* @cronus:data-end *\/`) and shared by every slot,
|
|
34
|
+
* so a block with two data-slots carries two close markers. We therefore pair the
|
|
35
|
+
* open marker with the FIRST close marker that follows it (its own region end),
|
|
36
|
+
* rather than rejecting any source in which the shared close marker repeats. This
|
|
37
|
+
* is deterministic and unambiguous because slot regions are flat siblings (never
|
|
38
|
+
* nested), so the first `data-end` after an `@cronus:data <name>` open always
|
|
39
|
+
* belongs to that same slot. The only ambiguity we still reject is a duplicated
|
|
40
|
+
* OPEN marker for this named slot (we would not know which region to rewrite).
|
|
41
|
+
*
|
|
42
|
+
* `source` — the on-disk block copy; `name` — the slot id; `replacement` — the
|
|
43
|
+
* new const source (no surrounding markers/newlines; they are re-added).
|
|
44
|
+
*/
|
|
45
|
+
export function replaceDataSlot(source, name, replacement) {
|
|
46
|
+
const { open, close } = dataSlotMarkers(name);
|
|
47
|
+
const openAt = source.indexOf(open);
|
|
48
|
+
if (openAt === -1) {
|
|
49
|
+
throw new DataSlotError(`data-slot "${name}": opening marker \`${open}\` not found in source`);
|
|
50
|
+
}
|
|
51
|
+
if (source.indexOf(open, openAt + open.length) !== -1) {
|
|
52
|
+
throw new DataSlotError(`data-slot "${name}": opening marker \`${open}\` appears more than once`);
|
|
53
|
+
}
|
|
54
|
+
// Pair with the FIRST close marker after this slot's open marker — that is this
|
|
55
|
+
// slot's region end. Other slots' close markers appearing later in the source
|
|
56
|
+
// are irrelevant (they close their own regions), so we do NOT reject them.
|
|
57
|
+
const closeAt = source.indexOf(close, openAt + open.length);
|
|
58
|
+
if (closeAt === -1) {
|
|
59
|
+
throw new DataSlotError(`data-slot "${name}": closing marker \`${close}\` not found after the opening marker`);
|
|
60
|
+
}
|
|
61
|
+
const before = source.slice(0, openAt);
|
|
62
|
+
const after = source.slice(closeAt + close.length);
|
|
63
|
+
return `${before}${open}\n${replacement}\n${close}${after}`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* JSX-escape a value destined for a JSX **text** context (element children). The
|
|
67
|
+
* chrome brand literal (`Cronus`) lives inside JSX text nodes — the navbar/footer
|
|
68
|
+
* wordmark `<span>…</span>` and the footer copyright `<p>…</p>` — so a raw
|
|
69
|
+
* substitution of a brand containing `<`, `>`, `{` or `}` would emit invalid TSX
|
|
70
|
+
* (`<`/`>` are token starts) or an undefined JSX expression container (`{Store}`),
|
|
71
|
+
* breaking `next build`. Escaping here makes ANY brand safe regardless of source,
|
|
72
|
+
* which is stronger than validate-and-reject. `&` is escaped first so we never
|
|
73
|
+
* double-escape the entities we introduce. A brand with none of these characters
|
|
74
|
+
* is returned unchanged (no-op for safe brands like `Acme`).
|
|
75
|
+
*/
|
|
76
|
+
export function escapeJsxText(value) {
|
|
77
|
+
return value
|
|
78
|
+
.replace(/&/g, "&")
|
|
79
|
+
.replace(/</g, "<")
|
|
80
|
+
.replace(/>/g, ">")
|
|
81
|
+
.replace(/\{/g, "{")
|
|
82
|
+
.replace(/\}/g, "}");
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Replace EVERY occurrence of a brand `literal` in the source with `brand`. Used
|
|
86
|
+
* for the chrome wordmark/copyright (`Cronus` → the app brand), which sit in JSX
|
|
87
|
+
* text nodes. Throws {@link DataSlotError} when the literal is absent so a
|
|
88
|
+
* renamed/removed anchor fails loud instead of leaving the placeholder brand.
|
|
89
|
+
*
|
|
90
|
+
* The `literal` MATCH is literal (not regex), so special characters in the search
|
|
91
|
+
* literal do not misbehave. The injected `brand`, however, lands in a JSX text
|
|
92
|
+
* context, so it is JSX-escaped ({@link escapeJsxText}) before substitution —
|
|
93
|
+
* otherwise a brand like `Tom > Jerry`, `Ben <Labs>`, `Acme {Store}` or `A & B`
|
|
94
|
+
* would emit invalid TSX / an undefined JSX expression and break `next build`.
|
|
95
|
+
* Escaping is a no-op for brands with no `& < > { }`, so safe brands are
|
|
96
|
+
* byte-identical to a raw substitution.
|
|
97
|
+
*/
|
|
98
|
+
export function replaceBrandLiteral(source, literal, brand) {
|
|
99
|
+
if (!source.includes(literal)) {
|
|
100
|
+
throw new DataSlotError(`brand literal "${literal}" not found in source`);
|
|
101
|
+
}
|
|
102
|
+
return source.split(literal).join(escapeJsxText(brand));
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=data-slots.js.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-manifest types + a strict, hand-rolled structural parser/validator (no new
|
|
3
|
+
* dep). The parser is deliberately paranoid: manifests arrive from disk (bundled
|
|
4
|
+
* template or `--manifest` file) and drive code generation, so an unknown
|
|
5
|
+
* `planVersion` or any unknown field is a HARD ERROR (D3 graft) rather than a
|
|
6
|
+
* silently-ignored typo. Only structural/shape checks live here — semantic checks
|
|
7
|
+
* that need the registry index/meta (does the block exist? does its kind fit the
|
|
8
|
+
* slot?) live in `plan.ts`.
|
|
9
|
+
*/
|
|
10
|
+
/** The only plan schema version F1 understands. Unknown values are rejected. */
|
|
11
|
+
export declare const SUPPORTED_PLAN_VERSION = 1;
|
|
12
|
+
/** A block reference in a page: a bare slug (default variant) or `{ block, variant }`. */
|
|
13
|
+
export type BlockRef = string | {
|
|
14
|
+
block: string;
|
|
15
|
+
variant?: string;
|
|
16
|
+
};
|
|
17
|
+
/** One route in the app. `nav` presence lists it in the generated navbar/footer. */
|
|
18
|
+
export interface Page {
|
|
19
|
+
/** App Router route, e.g. "/", "/products", "/products/[id]". */
|
|
20
|
+
route: string;
|
|
21
|
+
/** <title> metadata for the page. */
|
|
22
|
+
title: string;
|
|
23
|
+
/** Label shown in the nav; omit to keep the page out of the nav. */
|
|
24
|
+
nav?: string;
|
|
25
|
+
/** Which chrome group the page renders under (key of `chrome`). */
|
|
26
|
+
chrome: string;
|
|
27
|
+
/** The blocks stacked (top-to-bottom) inside the page's <main>. */
|
|
28
|
+
blocks: BlockRef[];
|
|
29
|
+
}
|
|
30
|
+
/** One chrome group → the route group + its layout furniture. F1 supports site/bare. */
|
|
31
|
+
export interface ChromeGroup {
|
|
32
|
+
/** Navbar block slug (chrome kind). Omit for a bare/centered group. */
|
|
33
|
+
navbar?: string;
|
|
34
|
+
/** Footer block slug (chrome kind). Omit for a bare/centered group. */
|
|
35
|
+
footer?: string;
|
|
36
|
+
/** App-shell block slug (F2 sidebar shell). Present in the type; unused in F1. */
|
|
37
|
+
block?: string;
|
|
38
|
+
}
|
|
39
|
+
export type ChromeMap = Record<string, ChromeGroup>;
|
|
40
|
+
/** App-level defaults baked into the generated app. */
|
|
41
|
+
export interface ManifestDefaults {
|
|
42
|
+
theme?: string;
|
|
43
|
+
mode?: string;
|
|
44
|
+
/** Brand wordmark; `__APP_NAME__` is substituted with the project name. */
|
|
45
|
+
brand?: string;
|
|
46
|
+
}
|
|
47
|
+
/** The manifest body (everything under the top-level `manifest` key). */
|
|
48
|
+
export interface ManifestBody {
|
|
49
|
+
title: string;
|
|
50
|
+
description: string;
|
|
51
|
+
chrome: ChromeMap;
|
|
52
|
+
pages: Page[];
|
|
53
|
+
/** Next special-file blocks by kind, e.g. `{ "not-found": "not-found" }`. */
|
|
54
|
+
extras?: Record<string, string>;
|
|
55
|
+
defaults?: ManifestDefaults;
|
|
56
|
+
}
|
|
57
|
+
/** A full app-template manifest as stored in `packages/cli/templates/apps/*.json`. */
|
|
58
|
+
export interface AppManifest {
|
|
59
|
+
name: string;
|
|
60
|
+
type: "registry:app";
|
|
61
|
+
planVersion: number;
|
|
62
|
+
manifest: ManifestBody;
|
|
63
|
+
}
|
|
64
|
+
/** Thrown by {@link parseManifest} with EVERY structural problem found, aggregated. */
|
|
65
|
+
export declare class ManifestError extends Error {
|
|
66
|
+
readonly errors: string[];
|
|
67
|
+
constructor(errors: string[]);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Parse + strictly validate a raw (JSON-parsed) manifest value into a typed
|
|
71
|
+
* {@link AppManifest}. Aggregates ALL structural problems and throws a single
|
|
72
|
+
* {@link ManifestError} listing them (never on the first). Rejects an unknown
|
|
73
|
+
* `planVersion` and any unknown field at every level (strict D3 graft) so a
|
|
74
|
+
* template typo can never be silently ignored by the generator.
|
|
75
|
+
*/
|
|
76
|
+
export declare function parseManifest(raw: unknown): AppManifest;
|
|
77
|
+
/** Normalize a block reference to its `{ slug, variant }` parts (default variant = undefined). */
|
|
78
|
+
export declare function blockRefParts(ref: BlockRef): {
|
|
79
|
+
slug: string;
|
|
80
|
+
variant?: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* A deterministic content fingerprint of a parsed manifest, used as compose
|
|
84
|
+
* provenance persisted in `composed{}.manifestHash`. Keyed on the manifest's
|
|
85
|
+
* meaningful shape (name + planVersion + body) via canonical sorted-key JSON, so
|
|
86
|
+
* two manifests fingerprint equal iff they describe the same app — independent of
|
|
87
|
+
* source key order or whitespace. Pure (no Date/random) so it stays byte-stable.
|
|
88
|
+
*/
|
|
89
|
+
export declare function manifestFingerprint(manifest: AppManifest): string;
|
|
90
|
+
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-manifest types + a strict, hand-rolled structural parser/validator (no new
|
|
3
|
+
* dep). The parser is deliberately paranoid: manifests arrive from disk (bundled
|
|
4
|
+
* template or `--manifest` file) and drive code generation, so an unknown
|
|
5
|
+
* `planVersion` or any unknown field is a HARD ERROR (D3 graft) rather than a
|
|
6
|
+
* silently-ignored typo. Only structural/shape checks live here — semantic checks
|
|
7
|
+
* that need the registry index/meta (does the block exist? does its kind fit the
|
|
8
|
+
* slot?) live in `plan.ts`.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
/** The only plan schema version F1 understands. Unknown values are rejected. */
|
|
12
|
+
export const SUPPORTED_PLAN_VERSION = 1;
|
|
13
|
+
/** Thrown by {@link parseManifest} with EVERY structural problem found, aggregated. */
|
|
14
|
+
export class ManifestError extends Error {
|
|
15
|
+
errors;
|
|
16
|
+
constructor(errors) {
|
|
17
|
+
super(`Invalid app manifest:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
18
|
+
this.name = "ManifestError";
|
|
19
|
+
this.errors = errors;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** True for a plain (non-array, non-null) object. */
|
|
23
|
+
function isRecord(value) {
|
|
24
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
/** Push an "unknown field" error for every key of `obj` not in `allowed`. */
|
|
27
|
+
function rejectUnknownKeys(obj, allowed, where, errors) {
|
|
28
|
+
for (const key of Object.keys(obj)) {
|
|
29
|
+
if (!allowed.includes(key)) {
|
|
30
|
+
errors.push(`${where}: unknown field "${key}" (allowed: ${allowed.join(", ")})`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const MANIFEST_TOP_KEYS = ["name", "type", "planVersion", "manifest"];
|
|
35
|
+
const MANIFEST_BODY_KEYS = [
|
|
36
|
+
"title",
|
|
37
|
+
"description",
|
|
38
|
+
"chrome",
|
|
39
|
+
"pages",
|
|
40
|
+
"extras",
|
|
41
|
+
"defaults",
|
|
42
|
+
];
|
|
43
|
+
const PAGE_KEYS = ["route", "title", "nav", "chrome", "blocks"];
|
|
44
|
+
const CHROME_GROUP_KEYS = ["navbar", "footer", "block"];
|
|
45
|
+
const DEFAULTS_KEYS = ["theme", "mode", "brand"];
|
|
46
|
+
const BLOCKREF_KEYS = ["block", "variant"];
|
|
47
|
+
function validateBlockRef(value, where, errors) {
|
|
48
|
+
if (typeof value === "string") {
|
|
49
|
+
if (value.length === 0)
|
|
50
|
+
errors.push(`${where}: block reference is an empty string`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!isRecord(value)) {
|
|
54
|
+
errors.push(`${where}: block reference must be a string or { block, variant }`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
rejectUnknownKeys(value, BLOCKREF_KEYS, where, errors);
|
|
58
|
+
if (typeof value.block !== "string" || value.block.length === 0) {
|
|
59
|
+
errors.push(`${where}: block reference object needs a non-empty "block" string`);
|
|
60
|
+
}
|
|
61
|
+
if (value.variant !== undefined && typeof value.variant !== "string") {
|
|
62
|
+
errors.push(`${where}: block "variant" must be a string when present`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function validateChromeGroup(value, where, errors) {
|
|
66
|
+
if (!isRecord(value)) {
|
|
67
|
+
errors.push(`${where}: chrome group must be an object`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
rejectUnknownKeys(value, CHROME_GROUP_KEYS, where, errors);
|
|
71
|
+
for (const key of ["navbar", "footer", "block"]) {
|
|
72
|
+
if (value[key] !== undefined && typeof value[key] !== "string") {
|
|
73
|
+
errors.push(`${where}: "${key}" must be a string when present`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function validatePage(value, index, errors) {
|
|
78
|
+
const where = `pages[${index}]`;
|
|
79
|
+
if (!isRecord(value)) {
|
|
80
|
+
errors.push(`${where}: page must be an object`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
rejectUnknownKeys(value, PAGE_KEYS, where, errors);
|
|
84
|
+
if (typeof value.route !== "string" || value.route.length === 0) {
|
|
85
|
+
errors.push(`${where}: "route" must be a non-empty string`);
|
|
86
|
+
}
|
|
87
|
+
if (typeof value.title !== "string" || value.title.length === 0) {
|
|
88
|
+
errors.push(`${where}: "title" must be a non-empty string`);
|
|
89
|
+
}
|
|
90
|
+
if (value.nav !== undefined && (typeof value.nav !== "string" || value.nav.length === 0)) {
|
|
91
|
+
errors.push(`${where}: "nav" must be a non-empty string when present`);
|
|
92
|
+
}
|
|
93
|
+
if (typeof value.chrome !== "string" || value.chrome.length === 0) {
|
|
94
|
+
errors.push(`${where}: "chrome" must be a non-empty string`);
|
|
95
|
+
}
|
|
96
|
+
if (!Array.isArray(value.blocks) || value.blocks.length === 0) {
|
|
97
|
+
errors.push(`${where}: "blocks" must be a non-empty array`);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
value.blocks.forEach((b, i) => {
|
|
101
|
+
validateBlockRef(b, `${where}.blocks[${i}]`, errors);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function validateBody(value, errors) {
|
|
106
|
+
if (!isRecord(value)) {
|
|
107
|
+
errors.push(`"manifest" must be an object`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
rejectUnknownKeys(value, MANIFEST_BODY_KEYS, "manifest", errors);
|
|
111
|
+
if (typeof value.title !== "string" || value.title.length === 0) {
|
|
112
|
+
errors.push(`manifest.title must be a non-empty string`);
|
|
113
|
+
}
|
|
114
|
+
if (typeof value.description !== "string") {
|
|
115
|
+
errors.push(`manifest.description must be a string`);
|
|
116
|
+
}
|
|
117
|
+
if (!isRecord(value.chrome)) {
|
|
118
|
+
errors.push(`manifest.chrome must be an object`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
for (const [key, group] of Object.entries(value.chrome)) {
|
|
122
|
+
validateChromeGroup(group, `manifest.chrome.${key}`, errors);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!Array.isArray(value.pages) || value.pages.length === 0) {
|
|
126
|
+
errors.push(`manifest.pages must be a non-empty array`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
value.pages.forEach((p, i) => {
|
|
130
|
+
validatePage(p, i, errors);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (value.extras !== undefined) {
|
|
134
|
+
if (!isRecord(value.extras)) {
|
|
135
|
+
errors.push(`manifest.extras must be an object when present`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
for (const [key, slug] of Object.entries(value.extras)) {
|
|
139
|
+
if (typeof slug !== "string" || slug.length === 0) {
|
|
140
|
+
errors.push(`manifest.extras.${key} must be a non-empty string`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (value.defaults !== undefined) {
|
|
146
|
+
if (!isRecord(value.defaults)) {
|
|
147
|
+
errors.push(`manifest.defaults must be an object when present`);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
rejectUnknownKeys(value.defaults, DEFAULTS_KEYS, "manifest.defaults", errors);
|
|
151
|
+
for (const key of DEFAULTS_KEYS) {
|
|
152
|
+
if (value.defaults[key] !== undefined && typeof value.defaults[key] !== "string") {
|
|
153
|
+
errors.push(`manifest.defaults.${key} must be a string when present`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Parse + strictly validate a raw (JSON-parsed) manifest value into a typed
|
|
161
|
+
* {@link AppManifest}. Aggregates ALL structural problems and throws a single
|
|
162
|
+
* {@link ManifestError} listing them (never on the first). Rejects an unknown
|
|
163
|
+
* `planVersion` and any unknown field at every level (strict D3 graft) so a
|
|
164
|
+
* template typo can never be silently ignored by the generator.
|
|
165
|
+
*/
|
|
166
|
+
export function parseManifest(raw) {
|
|
167
|
+
const errors = [];
|
|
168
|
+
if (!isRecord(raw)) {
|
|
169
|
+
throw new ManifestError(["manifest must be a JSON object"]);
|
|
170
|
+
}
|
|
171
|
+
rejectUnknownKeys(raw, MANIFEST_TOP_KEYS, "manifest root", errors);
|
|
172
|
+
if (typeof raw.name !== "string" || raw.name.length === 0) {
|
|
173
|
+
errors.push(`"name" must be a non-empty string`);
|
|
174
|
+
}
|
|
175
|
+
if (raw.type !== "registry:app") {
|
|
176
|
+
errors.push(`"type" must be "registry:app"`);
|
|
177
|
+
}
|
|
178
|
+
if (raw.planVersion !== SUPPORTED_PLAN_VERSION) {
|
|
179
|
+
errors.push(`unsupported "planVersion" ${JSON.stringify(raw.planVersion)} — ` +
|
|
180
|
+
`this CLI only understands planVersion ${SUPPORTED_PLAN_VERSION}.`);
|
|
181
|
+
}
|
|
182
|
+
validateBody(raw.manifest, errors);
|
|
183
|
+
if (errors.length > 0)
|
|
184
|
+
throw new ManifestError(errors);
|
|
185
|
+
// Every branch above is proven; the cast is safe.
|
|
186
|
+
return raw;
|
|
187
|
+
}
|
|
188
|
+
/** Normalize a block reference to its `{ slug, variant }` parts (default variant = undefined). */
|
|
189
|
+
export function blockRefParts(ref) {
|
|
190
|
+
if (typeof ref === "string")
|
|
191
|
+
return { slug: ref };
|
|
192
|
+
return ref.variant !== undefined
|
|
193
|
+
? { slug: ref.block, variant: ref.variant }
|
|
194
|
+
: { slug: ref.block };
|
|
195
|
+
}
|
|
196
|
+
/** Canonical (sorted-key) JSON of a value — deterministic regardless of key order. */
|
|
197
|
+
function canonicalJson(value) {
|
|
198
|
+
if (value === null || typeof value !== "object")
|
|
199
|
+
return JSON.stringify(value);
|
|
200
|
+
if (Array.isArray(value))
|
|
201
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
202
|
+
const obj = value;
|
|
203
|
+
const entries = Object.keys(obj)
|
|
204
|
+
.sort()
|
|
205
|
+
.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`);
|
|
206
|
+
return `{${entries.join(",")}}`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A deterministic content fingerprint of a parsed manifest, used as compose
|
|
210
|
+
* provenance persisted in `composed{}.manifestHash`. Keyed on the manifest's
|
|
211
|
+
* meaningful shape (name + planVersion + body) via canonical sorted-key JSON, so
|
|
212
|
+
* two manifests fingerprint equal iff they describe the same app — independent of
|
|
213
|
+
* source key order or whitespace. Pure (no Date/random) so it stays byte-stable.
|
|
214
|
+
*/
|
|
215
|
+
export function manifestFingerprint(manifest) {
|
|
216
|
+
const canonical = canonicalJson({
|
|
217
|
+
name: manifest.name,
|
|
218
|
+
type: manifest.type,
|
|
219
|
+
planVersion: manifest.planVersion,
|
|
220
|
+
manifest: manifest.manifest,
|
|
221
|
+
});
|
|
222
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure plan builder + validator. Turns a parsed {@link AppManifest} + caller
|
|
3
|
+
* `choices` into a {@link ComposePlan} the renderer consumes, and aggregates ALL
|
|
4
|
+
* semantic errors (block exists? kind fits slot? routes unique/valid? no
|
|
5
|
+
* dynamic-slug collisions Next rejects? chrome refs exist? extras resolve to a
|
|
6
|
+
* page-kind special file? deps safe?) before returning — never bailing on the
|
|
7
|
+
* first. Nothing here touches the filesystem; the command feeds it the registry
|
|
8
|
+
* index, meta, and shipped chrome sources.
|
|
9
|
+
*/
|
|
10
|
+
import type { ComposedChoices } from "../config.js";
|
|
11
|
+
import type { RegistryIndex } from "../registry.js";
|
|
12
|
+
import { type AppManifest } from "./manifest.js";
|
|
13
|
+
/** Semantic kind of a block (mirrors build-registry's BlockKind, read from meta). */
|
|
14
|
+
export type BlockKind = "page" | "section" | "chrome" | "email";
|
|
15
|
+
/** A brand-token anchor as recorded in meta. */
|
|
16
|
+
export interface BrandToken {
|
|
17
|
+
token: string;
|
|
18
|
+
literal: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A block variant as recorded in meta: its id + the registry `item` the composer
|
|
22
|
+
* installs (bare `<slug>` for the default, `<slug>--<id>` otherwise) + the
|
|
23
|
+
* component `exportName` a generated page imports.
|
|
24
|
+
*/
|
|
25
|
+
export interface VariantMetaRef {
|
|
26
|
+
id: string;
|
|
27
|
+
item: string;
|
|
28
|
+
exportName: string;
|
|
29
|
+
}
|
|
30
|
+
/** The subset of `registry/meta.json` the planner reads. */
|
|
31
|
+
export interface ComposeMetaBlock {
|
|
32
|
+
exportName: string;
|
|
33
|
+
kind: BlockKind;
|
|
34
|
+
dataSlots: string[];
|
|
35
|
+
brandTokens: BrandToken[];
|
|
36
|
+
/** Declared variants (default first); empty when the block has none. */
|
|
37
|
+
variants?: VariantMetaRef[];
|
|
38
|
+
}
|
|
39
|
+
export interface ComposeMeta {
|
|
40
|
+
blocks: Record<string, ComposeMetaBlock>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A resolved block inside a page: its family slug, the registry `item` name to
|
|
44
|
+
* install (bare `<slug>`, or `<slug>--<variant>` for a non-default variant), the
|
|
45
|
+
* `exportName` a generated page imports, the block kind, and the selected
|
|
46
|
+
* `variant` id when a non-default variant was chosen (absent for the default).
|
|
47
|
+
*/
|
|
48
|
+
export interface PlanBlock {
|
|
49
|
+
slug: string;
|
|
50
|
+
/** Registry item name to install/import from (`<slug>` or `<slug>--<variant>`). */
|
|
51
|
+
item: string;
|
|
52
|
+
exportName: string;
|
|
53
|
+
kind: BlockKind;
|
|
54
|
+
/** Selected non-default variant id (absent = default variant). */
|
|
55
|
+
variant?: string;
|
|
56
|
+
}
|
|
57
|
+
/** A resolved page ready to render. */
|
|
58
|
+
export interface PlanPage {
|
|
59
|
+
route: string;
|
|
60
|
+
title: string;
|
|
61
|
+
nav?: string;
|
|
62
|
+
chrome: string;
|
|
63
|
+
blocks: PlanBlock[];
|
|
64
|
+
}
|
|
65
|
+
/** A resolved chrome group (route group + its navbar/footer/shell slugs). */
|
|
66
|
+
export interface PlanChrome {
|
|
67
|
+
group: string;
|
|
68
|
+
navbar?: string;
|
|
69
|
+
footer?: string;
|
|
70
|
+
/** App-shell chrome block slug (sidebar shell), when the group uses one. */
|
|
71
|
+
block?: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A resolved Next special-file block (from `manifest.extras`). `file` is the
|
|
75
|
+
* emitted Next special-file path (e.g. `app/not-found.tsx`) and the block is a
|
|
76
|
+
* page-kind block wrapped by the renderer under the golden rule.
|
|
77
|
+
*/
|
|
78
|
+
export interface PlanExtra {
|
|
79
|
+
/** The extras KEY = the Next special-file name (e.g. "not-found"). */
|
|
80
|
+
key: string;
|
|
81
|
+
/** Emitted file path under `app/` (e.g. "app/not-found.tsx"). */
|
|
82
|
+
file: string;
|
|
83
|
+
/** The resolved block slug for this special file. */
|
|
84
|
+
slug: string;
|
|
85
|
+
/** The block's meta export name (what the wrapper imports). */
|
|
86
|
+
exportName: string;
|
|
87
|
+
}
|
|
88
|
+
/** The fully-resolved, validated plan the renderer turns into files. */
|
|
89
|
+
export interface ComposePlan {
|
|
90
|
+
/** The template/manifest name — the key of the `composed{}` record. */
|
|
91
|
+
templateName: string;
|
|
92
|
+
/** The project name (brand default + `__APP_NAME__` + legacy snapshot-dir fallback). */
|
|
93
|
+
appName: string;
|
|
94
|
+
planVersion: number;
|
|
95
|
+
title: string;
|
|
96
|
+
description: string;
|
|
97
|
+
choices: ComposedChoices;
|
|
98
|
+
pages: PlanPage[];
|
|
99
|
+
chromes: PlanChrome[];
|
|
100
|
+
/** Resolved Next special-file blocks (from `manifest.extras`), sorted by key. */
|
|
101
|
+
extras: PlanExtra[];
|
|
102
|
+
/**
|
|
103
|
+
* Every unique registry ITEM the plan installs (pages + chrome + extras),
|
|
104
|
+
* sorted. Item names, not family slugs: a non-default variant contributes its
|
|
105
|
+
* `<slug>--<variant>` item, so this is what the composer resolves/installs.
|
|
106
|
+
*/
|
|
107
|
+
blockSlugs: string[];
|
|
108
|
+
/** Chrome block slugs used by any group, sorted. */
|
|
109
|
+
chromeSlugs: string[];
|
|
110
|
+
/** Shipped source per chrome slug (for the in-place data-slot/brand rewrite). */
|
|
111
|
+
chromeSources: Record<string, string>;
|
|
112
|
+
/** Declared data-slots per chrome slug (from meta). */
|
|
113
|
+
dataSlotsBySlug: Record<string, string[]>;
|
|
114
|
+
/** Declared brand tokens per chrome slug (from meta). */
|
|
115
|
+
brandTokensBySlug: Record<string, BrandToken[]>;
|
|
116
|
+
/** Whether any group uses a navbar / footer / shell (drives wrapper emission). */
|
|
117
|
+
usesNavbar: boolean;
|
|
118
|
+
usesFooter: boolean;
|
|
119
|
+
usesShell: boolean;
|
|
120
|
+
navbarSlug?: string;
|
|
121
|
+
footerSlug?: string;
|
|
122
|
+
shellSlug?: string;
|
|
123
|
+
navbarExportName?: string;
|
|
124
|
+
footerExportName?: string;
|
|
125
|
+
shellExportName?: string;
|
|
126
|
+
}
|
|
127
|
+
/** Thrown by {@link buildComposePlan} carrying EVERY semantic error found. */
|
|
128
|
+
export declare class ComposePlanError extends Error {
|
|
129
|
+
readonly errors: string[];
|
|
130
|
+
constructor(errors: string[]);
|
|
131
|
+
}
|
|
132
|
+
/** Options a caller passes to shape the plan (F1: brand, seed, page subset). */
|
|
133
|
+
export interface ComposeChoiceInput {
|
|
134
|
+
/** Brand wordmark; falls back to manifest default (with `__APP_NAME__` filled) then appName. */
|
|
135
|
+
brand?: string;
|
|
136
|
+
/** Aesthetic PRNG seed (recorded when present; F1 renderer does not vary on it yet). */
|
|
137
|
+
seed?: number;
|
|
138
|
+
/** Route subset to include (F2); when omitted, all manifest pages are used. */
|
|
139
|
+
pages?: string[];
|
|
140
|
+
/**
|
|
141
|
+
* Per-family variant override (`{ login: "split" }`); a present entry wins over
|
|
142
|
+
* the manifest ref's declared variant for that slug. Recorded in `composed{}`.
|
|
143
|
+
*/
|
|
144
|
+
variants?: Record<string, string>;
|
|
145
|
+
/** Project name, used to fill `__APP_NAME__` in the manifest brand default. */
|
|
146
|
+
appName?: string;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Build a {@link ComposePlan} from a parsed manifest, caller choices, the registry
|
|
150
|
+
* index, meta, and the shipped chrome sources. Aggregates ALL semantic errors and
|
|
151
|
+
* THROWS {@link ComposePlanError} when invalid. The returned plan is a pure
|
|
152
|
+
* function of its inputs (stable ordering, no I/O/Date).
|
|
153
|
+
*/
|
|
154
|
+
export declare function buildComposePlan(manifest: AppManifest, input: ComposeChoiceInput, index: RegistryIndex, meta: ComposeMeta, chromeSources: Record<string, string>): ComposePlan;
|
|
155
|
+
/**
|
|
156
|
+
* Validate-only entry point: builds the plan and returns the aggregated error
|
|
157
|
+
* list (empty when valid) instead of throwing. Handy for `--dry-run` callers and
|
|
158
|
+
* tests that assert on the full error set. The successful plan is returned too.
|
|
159
|
+
*/
|
|
160
|
+
export declare function validateComposePlan(manifest: AppManifest, input: ComposeChoiceInput, index: RegistryIndex, meta: ComposeMeta, chromeSources: Record<string, string>): {
|
|
161
|
+
plan?: ComposePlan;
|
|
162
|
+
errors: string[];
|
|
163
|
+
};
|
|
164
|
+
//# sourceMappingURL=plan.d.ts.map
|