schema.ts 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162
  1. import { z } from "zod";
  2. /**
  3. * Schema builder primitives
  4. */
  5. export interface SchemaBuilder {
  6. /** String type */
  7. string(): SchemaType<"string">;
  8. /** Number type */
  9. number(): SchemaType<"number">;
  10. /** Boolean type */
  11. boolean(): SchemaType<"boolean">;
  12. /** Array of type */
  13. array<T extends SchemaType>(item: T): SchemaType<"array", T>;
  14. /** Object with shape */
  15. object<T extends Record<string, SchemaType>>(
  16. shape: T,
  17. ): SchemaType<"object", T>;
  18. /** Record/map with value type */
  19. record<T extends SchemaType>(value: T): SchemaType<"record", T>;
  20. /** Any type */
  21. any(): SchemaType<"any">;
  22. /** Placeholder for user-provided Zod schema */
  23. zod(): SchemaType<"zod">;
  24. /** Reference to catalog key (e.g., 'catalog.components') */
  25. ref(path: string): SchemaType<"ref", string>;
  26. /** Props from referenced catalog entry */
  27. propsOf(path: string): SchemaType<"propsOf", string>;
  28. /** Map of named entries with shared shape */
  29. map<T extends Record<string, SchemaType>>(
  30. entryShape: T,
  31. ): SchemaType<"map", T>;
  32. /** Optional modifier */
  33. optional(): { optional: true };
  34. }
  35. /**
  36. * Schema type representation
  37. */
  38. export interface SchemaType<TKind extends string = string, TInner = unknown> {
  39. kind: TKind;
  40. inner?: TInner;
  41. optional?: boolean;
  42. }
  43. /**
  44. * Schema definition shape
  45. */
  46. export interface SchemaDefinition<
  47. TSpec extends SchemaType = SchemaType,
  48. TCatalog extends SchemaType = SchemaType,
  49. > {
  50. /** What the AI-generated spec looks like */
  51. spec: TSpec;
  52. /** What the catalog must provide */
  53. catalog: TCatalog;
  54. }
  55. /**
  56. * Schema instance with methods
  57. */
  58. export interface Schema<TDef extends SchemaDefinition = SchemaDefinition> {
  59. /** The schema definition */
  60. readonly definition: TDef;
  61. /** Custom prompt template for this schema */
  62. readonly promptTemplate?: PromptTemplate;
  63. /** Default rules baked into the schema (injected before customRules) */
  64. readonly defaultRules?: string[];
  65. /** Create a catalog from this schema */
  66. createCatalog<TCatalog extends InferCatalogInput<TDef["catalog"]>>(
  67. catalog: TCatalog,
  68. ): Catalog<TDef, TCatalog>;
  69. }
  70. /**
  71. * Catalog instance with methods
  72. */
  73. export interface Catalog<
  74. TDef extends SchemaDefinition = SchemaDefinition,
  75. TCatalog = unknown,
  76. > {
  77. /** The schema this catalog is based on */
  78. readonly schema: Schema<TDef>;
  79. /** The catalog data */
  80. readonly data: TCatalog;
  81. /** Component names */
  82. readonly componentNames: string[];
  83. /** Action names */
  84. readonly actionNames: string[];
  85. /** Generate system prompt for AI */
  86. prompt(options?: PromptOptions): string;
  87. /** Export as JSON Schema for structured outputs */
  88. jsonSchema(): object;
  89. /** Validate a spec against this catalog */
  90. validate(spec: unknown): SpecValidationResult<InferSpec<TDef, TCatalog>>;
  91. /** Get the Zod schema for the spec */
  92. zodSchema(): z.ZodType<InferSpec<TDef, TCatalog>>;
  93. /** Type helper for the spec type */
  94. readonly _specType: InferSpec<TDef, TCatalog>;
  95. }
  96. /**
  97. * Prompt generation options
  98. */
  99. export interface PromptOptions {
  100. /** Custom system message intro */
  101. system?: string;
  102. /** Additional rules to append */
  103. customRules?: string[];
  104. }
  105. /**
  106. * Context provided to prompt templates
  107. */
  108. export interface PromptContext<TCatalog = unknown> {
  109. /** The catalog data */
  110. catalog: TCatalog;
  111. /** Component names from the catalog */
  112. componentNames: string[];
  113. /** Action names from the catalog (if any) */
  114. actionNames: string[];
  115. /** Prompt options provided by the user */
  116. options: PromptOptions;
  117. /** Helper to format a Zod type as a human-readable string */
  118. formatZodType: (schema: z.ZodType) => string;
  119. }
  120. /**
  121. * Prompt template function type
  122. */
  123. export type PromptTemplate<TCatalog = unknown> = (
  124. context: PromptContext<TCatalog>,
  125. ) => string;
  126. /**
  127. * Schema options
  128. */
  129. export interface SchemaOptions<TCatalog = unknown> {
  130. /** Custom prompt template for this schema */
  131. promptTemplate?: PromptTemplate<TCatalog>;
  132. /** Default rules baked into the schema (injected before customRules in prompts) */
  133. defaultRules?: string[];
  134. }
  135. /**
  136. * Spec validation result
  137. */
  138. export interface SpecValidationResult<T> {
  139. success: boolean;
  140. data?: T;
  141. error?: z.ZodError;
  142. }
  143. // =============================================================================
  144. // Catalog Type Inference Helpers
  145. // =============================================================================
  146. /**
  147. * Extract the components map type from a catalog
  148. * @example type Components = InferCatalogComponents<typeof myCatalog>;
  149. */
  150. export type InferCatalogComponents<C extends Catalog> =
  151. C extends Catalog<SchemaDefinition, infer TCatalog>
  152. ? TCatalog extends { components: infer Comps }
  153. ? Comps
  154. : never
  155. : never;
  156. /**
  157. * Extract the actions map type from a catalog
  158. * @example type Actions = InferCatalogActions<typeof myCatalog>;
  159. */
  160. export type InferCatalogActions<C extends Catalog> =
  161. C extends Catalog<SchemaDefinition, infer TCatalog>
  162. ? TCatalog extends { actions: infer Acts }
  163. ? Acts
  164. : never
  165. : never;
  166. /**
  167. * Infer component props from a catalog by component name
  168. * @example type ButtonProps = InferComponentProps<typeof myCatalog, 'Button'>;
  169. */
  170. export type InferComponentProps<
  171. C extends Catalog,
  172. K extends keyof InferCatalogComponents<C>,
  173. > = InferCatalogComponents<C>[K] extends { props: z.ZodType<infer P> }
  174. ? P
  175. : never;
  176. /**
  177. * Infer action params from a catalog by action name
  178. * @example type ViewCustomersParams = InferActionParams<typeof myCatalog, 'viewCustomers'>;
  179. */
  180. export type InferActionParams<
  181. C extends Catalog,
  182. K extends keyof InferCatalogActions<C>,
  183. > = InferCatalogActions<C>[K] extends { params: z.ZodType<infer P> }
  184. ? P
  185. : never;
  186. // =============================================================================
  187. // Internal Type Inference Helpers
  188. // =============================================================================
  189. export type InferCatalogInput<T> =
  190. T extends SchemaType<"object", infer Shape>
  191. ? { [K in keyof Shape]: InferCatalogField<Shape[K]> }
  192. : never;
  193. type InferCatalogField<T> =
  194. T extends SchemaType<"map", infer EntryShape>
  195. ? Record<
  196. string,
  197. // Only 'props' is required, rest are optional
  198. InferMapEntryRequired<EntryShape> &
  199. Partial<InferMapEntryOptional<EntryShape>>
  200. >
  201. : T extends SchemaType<"zod">
  202. ? z.ZodType
  203. : T extends SchemaType<"string">
  204. ? string
  205. : T extends SchemaType<"number">
  206. ? number
  207. : T extends SchemaType<"boolean">
  208. ? boolean
  209. : T extends SchemaType<"array", infer Item>
  210. ? InferCatalogField<Item>[]
  211. : T extends SchemaType<"object", infer Shape>
  212. ? { [K in keyof Shape]: InferCatalogField<Shape[K]> }
  213. : unknown;
  214. // Extract required fields (props is always required)
  215. type InferMapEntryRequired<T> = {
  216. [K in keyof T as K extends "props" ? K : never]: InferMapEntryField<T[K]>;
  217. };
  218. // Extract optional fields (everything except props)
  219. type InferMapEntryOptional<T> = {
  220. [K in keyof T as K extends "props" ? never : K]: InferMapEntryField<T[K]>;
  221. };
  222. type InferMapEntryField<T> =
  223. T extends SchemaType<"zod">
  224. ? z.ZodType
  225. : T extends SchemaType<"string">
  226. ? string
  227. : T extends SchemaType<"number">
  228. ? number
  229. : T extends SchemaType<"boolean">
  230. ? boolean
  231. : T extends SchemaType<"array", infer Item>
  232. ? InferMapEntryField<Item>[]
  233. : T extends SchemaType<"object", infer Shape>
  234. ? { [K in keyof Shape]: InferMapEntryField<Shape[K]> }
  235. : unknown;
  236. // Spec inference (simplified - will be expanded)
  237. export type InferSpec<TDef extends SchemaDefinition, TCatalog> = TDef extends {
  238. spec: SchemaType<"object", infer Shape>;
  239. }
  240. ? InferSpecObject<Shape, TCatalog>
  241. : unknown;
  242. type InferSpecObject<Shape, TCatalog> = {
  243. [K in keyof Shape]: InferSpecField<Shape[K], TCatalog>;
  244. };
  245. type InferSpecField<T, TCatalog> =
  246. T extends SchemaType<"string">
  247. ? string
  248. : T extends SchemaType<"number">
  249. ? number
  250. : T extends SchemaType<"boolean">
  251. ? boolean
  252. : T extends SchemaType<"array", infer Item>
  253. ? InferSpecField<Item, TCatalog>[]
  254. : T extends SchemaType<"object", infer Shape>
  255. ? InferSpecObject<Shape, TCatalog>
  256. : T extends SchemaType<"record", infer Value>
  257. ? Record<string, InferSpecField<Value, TCatalog>>
  258. : T extends SchemaType<"ref", infer Path>
  259. ? InferRefType<Path, TCatalog>
  260. : T extends SchemaType<"propsOf", infer Path>
  261. ? InferPropsOfType<Path, TCatalog>
  262. : T extends SchemaType<"any">
  263. ? unknown
  264. : unknown;
  265. type InferRefType<Path, TCatalog> = Path extends "catalog.components"
  266. ? TCatalog extends { components: infer C }
  267. ? keyof C
  268. : string
  269. : Path extends "catalog.actions"
  270. ? TCatalog extends { actions: infer A }
  271. ? keyof A
  272. : string
  273. : string;
  274. type InferPropsOfType<Path, TCatalog> = Path extends "catalog.components"
  275. ? TCatalog extends { components: infer C }
  276. ? C extends Record<string, { props: z.ZodType<infer P> }>
  277. ? P
  278. : Record<string, unknown>
  279. : Record<string, unknown>
  280. : Record<string, unknown>;
  281. /**
  282. * Create the schema builder
  283. */
  284. function createBuilder(): SchemaBuilder {
  285. return {
  286. string: () => ({ kind: "string" }),
  287. number: () => ({ kind: "number" }),
  288. boolean: () => ({ kind: "boolean" }),
  289. array: (item) => ({ kind: "array", inner: item }),
  290. object: (shape) => ({ kind: "object", inner: shape }),
  291. record: (value) => ({ kind: "record", inner: value }),
  292. any: () => ({ kind: "any" }),
  293. zod: () => ({ kind: "zod" }),
  294. ref: (path) => ({ kind: "ref", inner: path }),
  295. propsOf: (path) => ({ kind: "propsOf", inner: path }),
  296. map: (entryShape) => ({ kind: "map", inner: entryShape }),
  297. optional: () => ({ optional: true }),
  298. };
  299. }
  300. /**
  301. * Define a schema using the builder pattern
  302. */
  303. export function defineSchema<TDef extends SchemaDefinition>(
  304. builder: (s: SchemaBuilder) => TDef,
  305. options?: SchemaOptions,
  306. ): Schema<TDef> {
  307. const s = createBuilder();
  308. const definition = builder(s);
  309. return {
  310. definition,
  311. promptTemplate: options?.promptTemplate,
  312. defaultRules: options?.defaultRules,
  313. createCatalog<TCatalog extends InferCatalogInput<TDef["catalog"]>>(
  314. catalog: TCatalog,
  315. ): Catalog<TDef, TCatalog> {
  316. return createCatalogFromSchema(this as Schema<TDef>, catalog);
  317. },
  318. };
  319. }
  320. /**
  321. * Create a catalog from a schema (internal)
  322. */
  323. function createCatalogFromSchema<TDef extends SchemaDefinition, TCatalog>(
  324. schema: Schema<TDef>,
  325. catalogData: TCatalog,
  326. ): Catalog<TDef, TCatalog> {
  327. // Extract component and action names
  328. const components = (catalogData as Record<string, unknown>).components as
  329. | Record<string, unknown>
  330. | undefined;
  331. const actions = (catalogData as Record<string, unknown>).actions as
  332. | Record<string, unknown>
  333. | undefined;
  334. const componentNames = components ? Object.keys(components) : [];
  335. const actionNames = actions ? Object.keys(actions) : [];
  336. // Build the Zod schema for validation
  337. const zodSchema = buildZodSchemaFromDefinition(
  338. schema.definition,
  339. catalogData,
  340. );
  341. return {
  342. schema,
  343. data: catalogData,
  344. componentNames,
  345. actionNames,
  346. prompt(options: PromptOptions = {}): string {
  347. return generatePrompt(this, options);
  348. },
  349. jsonSchema(): object {
  350. return zodToJsonSchema(zodSchema);
  351. },
  352. validate(spec: unknown): SpecValidationResult<InferSpec<TDef, TCatalog>> {
  353. const result = zodSchema.safeParse(spec);
  354. if (result.success) {
  355. return {
  356. success: true,
  357. data: result.data as InferSpec<TDef, TCatalog>,
  358. };
  359. }
  360. return { success: false, error: result.error };
  361. },
  362. zodSchema(): z.ZodType<InferSpec<TDef, TCatalog>> {
  363. return zodSchema as z.ZodType<InferSpec<TDef, TCatalog>>;
  364. },
  365. get _specType(): InferSpec<TDef, TCatalog> {
  366. throw new Error("_specType is only for type inference");
  367. },
  368. };
  369. }
  370. /**
  371. * Build Zod schema from schema definition
  372. */
  373. function buildZodSchemaFromDefinition(
  374. definition: SchemaDefinition,
  375. catalogData: unknown,
  376. ): z.ZodType {
  377. return buildZodType(definition.spec, catalogData);
  378. }
  379. function buildZodType(schemaType: SchemaType, catalogData: unknown): z.ZodType {
  380. switch (schemaType.kind) {
  381. case "string":
  382. return z.string();
  383. case "number":
  384. return z.number();
  385. case "boolean":
  386. return z.boolean();
  387. case "any":
  388. return z.any();
  389. case "array": {
  390. const inner = buildZodType(schemaType.inner as SchemaType, catalogData);
  391. return z.array(inner);
  392. }
  393. case "object": {
  394. const shape = schemaType.inner as Record<string, SchemaType>;
  395. const zodShape: Record<string, z.ZodType> = {};
  396. for (const [key, value] of Object.entries(shape)) {
  397. let zodType = buildZodType(value, catalogData);
  398. if (value.optional) {
  399. zodType = zodType.optional();
  400. }
  401. zodShape[key] = zodType;
  402. }
  403. return z.object(zodShape);
  404. }
  405. case "record": {
  406. const inner = buildZodType(schemaType.inner as SchemaType, catalogData);
  407. return z.record(z.string(), inner);
  408. }
  409. case "ref": {
  410. // Reference to catalog key - create enum of valid keys
  411. const path = schemaType.inner as string;
  412. const keys = getKeysFromPath(path, catalogData);
  413. if (keys.length === 0) {
  414. return z.string();
  415. }
  416. if (keys.length === 1) {
  417. return z.literal(keys[0]!);
  418. }
  419. return z.enum(keys as [string, ...string[]]);
  420. }
  421. case "propsOf": {
  422. // Props from catalog entry - create union of all props schemas
  423. const path = schemaType.inner as string;
  424. const propsSchemas = getPropsFromPath(path, catalogData);
  425. if (propsSchemas.length === 0) {
  426. return z.record(z.string(), z.unknown());
  427. }
  428. if (propsSchemas.length === 1) {
  429. return propsSchemas[0]!;
  430. }
  431. // For propsOf, we need to be lenient since type determines which props apply
  432. return z.record(z.string(), z.unknown());
  433. }
  434. default:
  435. return z.unknown();
  436. }
  437. }
  438. function getKeysFromPath(path: string, catalogData: unknown): string[] {
  439. const parts = path.split(".");
  440. let current: unknown = { catalog: catalogData };
  441. for (const part of parts) {
  442. if (current && typeof current === "object") {
  443. current = (current as Record<string, unknown>)[part];
  444. } else {
  445. return [];
  446. }
  447. }
  448. if (current && typeof current === "object") {
  449. return Object.keys(current);
  450. }
  451. return [];
  452. }
  453. function getPropsFromPath(path: string, catalogData: unknown): z.ZodType[] {
  454. const parts = path.split(".");
  455. let current: unknown = { catalog: catalogData };
  456. for (const part of parts) {
  457. if (current && typeof current === "object") {
  458. current = (current as Record<string, unknown>)[part];
  459. } else {
  460. return [];
  461. }
  462. }
  463. if (current && typeof current === "object") {
  464. return Object.values(current as Record<string, { props?: z.ZodType }>)
  465. .map((entry) => entry.props)
  466. .filter((props): props is z.ZodType => props !== undefined);
  467. }
  468. return [];
  469. }
  470. /**
  471. * Generate system prompt from catalog
  472. */
  473. function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
  474. catalog: Catalog<TDef, TCatalog>,
  475. options: PromptOptions,
  476. ): string {
  477. // Check if schema has a custom prompt template
  478. if (catalog.schema.promptTemplate) {
  479. const context: PromptContext<TCatalog> = {
  480. catalog: catalog.data,
  481. componentNames: catalog.componentNames,
  482. actionNames: catalog.actionNames,
  483. options,
  484. formatZodType,
  485. };
  486. return catalog.schema.promptTemplate(context);
  487. }
  488. // Default JSONL element-tree format (for @json-render/react and similar)
  489. const {
  490. system = "You are a UI generator that outputs JSON.",
  491. customRules = [],
  492. } = options;
  493. const lines: string[] = [];
  494. lines.push(system);
  495. lines.push("");
  496. // Output format section - explain JSONL streaming patch format
  497. lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):");
  498. lines.push(
  499. "Output JSONL (one JSON object per line) using RFC 6902 JSON Patch operations to build a UI tree.",
  500. );
  501. lines.push(
  502. "Each line is a JSON patch operation (add, remove, replace). Start with /root, then stream /elements and /state patches interleaved so the UI fills in progressively as it streams.",
  503. );
  504. lines.push("");
  505. lines.push("Example output (each line is a separate JSON object):");
  506. lines.push("");
  507. // Build example using actual catalog component names and props to avoid hallucinations
  508. const allComponents = (catalog.data as Record<string, unknown>).components as
  509. | Record<string, CatalogComponentDef>
  510. | undefined;
  511. const cn = catalog.componentNames;
  512. const comp1 = cn[0] || "Component";
  513. const comp2 = cn.length > 1 ? cn[1]! : comp1;
  514. const comp1Def = allComponents?.[comp1];
  515. const comp2Def = allComponents?.[comp2];
  516. const comp1Props = comp1Def ? getExampleProps(comp1Def) : {};
  517. const comp2Props = comp2Def ? getExampleProps(comp2Def) : {};
  518. // Find a string prop on comp2 to demonstrate $path dynamic bindings
  519. const dynamicPropName = comp2Def?.props
  520. ? findFirstStringProp(comp2Def.props)
  521. : null;
  522. const dynamicProps = dynamicPropName
  523. ? { ...comp2Props, [dynamicPropName]: { $path: "$item/title" } }
  524. : comp2Props;
  525. const exampleOutput = [
  526. JSON.stringify({ op: "add", path: "/root", value: "main" }),
  527. JSON.stringify({
  528. op: "add",
  529. path: "/elements/main",
  530. value: {
  531. type: comp1,
  532. props: comp1Props,
  533. children: ["child-1", "list"],
  534. },
  535. }),
  536. JSON.stringify({
  537. op: "add",
  538. path: "/elements/child-1",
  539. value: { type: comp2, props: comp2Props, children: [] },
  540. }),
  541. JSON.stringify({
  542. op: "add",
  543. path: "/elements/list",
  544. value: {
  545. type: comp1,
  546. props: comp1Props,
  547. repeat: { path: "/items", key: "id" },
  548. children: ["item"],
  549. },
  550. }),
  551. JSON.stringify({
  552. op: "add",
  553. path: "/elements/item",
  554. value: { type: comp2, props: dynamicProps, children: [] },
  555. }),
  556. JSON.stringify({ op: "add", path: "/state/items", value: [] }),
  557. JSON.stringify({
  558. op: "add",
  559. path: "/state/items/0",
  560. value: { id: "1", title: "First Item" },
  561. }),
  562. JSON.stringify({
  563. op: "add",
  564. path: "/state/items/1",
  565. value: { id: "2", title: "Second Item" },
  566. }),
  567. ].join("\n");
  568. lines.push(`${exampleOutput}
  569. Note: state patches appear right after the elements that use them, so the UI fills in as it streams. ONLY use component types from the AVAILABLE COMPONENTS list below.`);
  570. lines.push("");
  571. // Initial state section
  572. lines.push("INITIAL STATE:");
  573. lines.push(
  574. "Specs include a /state field to seed the state model. Components with statePath read from and write to this state, and $path expressions read from it.",
  575. );
  576. lines.push(
  577. "CRITICAL: You MUST include state patches whenever your UI displays data via $path expressions, uses repeat to iterate over arrays, or uses statePath bindings. Without state, $path references resolve to nothing and repeat lists render zero items.",
  578. );
  579. lines.push(
  580. "Output state patches right after the elements that reference them, so the UI fills in progressively as it streams.",
  581. );
  582. lines.push(
  583. "Stream state progressively - output one patch per array item instead of one giant blob:",
  584. );
  585. lines.push(
  586. ' For arrays: {"op":"add","path":"/state/posts/0","value":{"id":"1","title":"First Post",...}} then /state/posts/1, /state/posts/2, etc.',
  587. );
  588. lines.push(
  589. ' For scalars: {"op":"add","path":"/state/newTodoText","value":""}',
  590. );
  591. lines.push(
  592. ' Initialize the array first if needed: {"op":"add","path":"/state/posts","value":[]}',
  593. );
  594. lines.push(
  595. 'When content comes from the state model, use { "$path": "/some/path" } dynamic props to display it instead of hardcoding the same value in both state and props. The state model is the single source of truth.',
  596. );
  597. lines.push(
  598. "Include realistic sample data in state. For blogs: 3-4 posts with titles, excerpts, authors, dates. For product lists: 3-5 items with names, prices, descriptions. Never leave arrays empty.",
  599. );
  600. lines.push("");
  601. lines.push("DYNAMIC LISTS (repeat field):");
  602. lines.push(
  603. 'Any element can have a top-level "repeat" field to render its children once per item in a state array: { "repeat": { "path": "/arrayPath", "key": "id" } }.',
  604. );
  605. lines.push(
  606. 'The element itself renders once (as the container), and its children are expanded once per array item. "path" is the state array path. "key" is an optional field name on each item for stable React keys.',
  607. );
  608. lines.push(
  609. `Example: ${JSON.stringify({ type: comp1, props: comp1Props, repeat: { path: "/todos", key: "id" }, children: ["todo-item"] })}`,
  610. );
  611. lines.push(
  612. 'Inside children of a repeated element, use "$item/field" for per-item paths: statePath:"$item/completed", { "$path": "$item/title" }. Use "$index" for the current array index.',
  613. );
  614. lines.push(
  615. "ALWAYS use the repeat field for lists backed by state arrays. NEVER hardcode individual elements for each array item.",
  616. );
  617. lines.push(
  618. 'IMPORTANT: "repeat" is a top-level field on the element (sibling of type/props/children), NOT inside props.',
  619. );
  620. lines.push("");
  621. lines.push("ARRAY STATE ACTIONS:");
  622. lines.push(
  623. 'Use action "pushState" to append items to arrays. Params: { path: "/arrayPath", value: { ...item }, clearPath: "/inputPath" }.',
  624. );
  625. lines.push(
  626. 'Values inside pushState can contain { "path": "/statePath" } references to read current state (e.g. the text from an input field).',
  627. );
  628. lines.push(
  629. 'Use "$id" inside a pushState value to auto-generate a unique ID.',
  630. );
  631. lines.push(
  632. 'Example: on: { "press": { "action": "pushState", "params": { "path": "/todos", "value": { "id": "$id", "title": { "path": "/newTodoText" }, "completed": false }, "clearPath": "/newTodoText" } } }',
  633. );
  634. lines.push(
  635. 'Use action "removeState" to remove items from arrays by index. Params: { path: "/arrayPath", index: N }. Inside a repeated element\'s children, use "$index" for the current item index.',
  636. );
  637. lines.push(
  638. "For lists where users can add/remove items (todos, carts, etc.), use pushState and removeState instead of hardcoding with setState.",
  639. );
  640. lines.push("");
  641. lines.push(
  642. 'IMPORTANT: State paths use RFC 6901 JSON Pointer syntax (e.g. "/todos/0/title"). Do NOT use JavaScript-style dot notation (e.g. "/todos.length" is WRONG). To generate unique IDs for new items, use "$id" instead of trying to read array length.',
  643. );
  644. lines.push("");
  645. // Components section — reuse the typed reference from example generation
  646. const components = allComponents;
  647. if (components) {
  648. lines.push(`AVAILABLE COMPONENTS (${catalog.componentNames.length}):`);
  649. lines.push("");
  650. for (const [name, def] of Object.entries(components)) {
  651. const propsStr = def.props ? formatZodType(def.props) : "{}";
  652. const hasChildren = def.slots && def.slots.length > 0;
  653. const childrenStr = hasChildren ? " [accepts children]" : "";
  654. const eventsStr =
  655. def.events && def.events.length > 0
  656. ? ` [events: ${def.events.join(", ")}]`
  657. : "";
  658. const descStr = def.description ? ` - ${def.description}` : "";
  659. lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`);
  660. }
  661. lines.push("");
  662. }
  663. // Actions section
  664. const actions = (catalog.data as Record<string, unknown>).actions as
  665. | Record<string, { params?: z.ZodType; description?: string }>
  666. | undefined;
  667. if (actions && catalog.actionNames.length > 0) {
  668. lines.push("AVAILABLE ACTIONS:");
  669. lines.push("");
  670. for (const [name, def] of Object.entries(actions)) {
  671. lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
  672. }
  673. lines.push("");
  674. }
  675. // Events section
  676. lines.push("EVENTS (the `on` field):");
  677. lines.push(
  678. "Elements can have an optional `on` field to bind events to actions. The `on` field is a top-level field on the element (sibling of type/props/children), NOT inside props.",
  679. );
  680. lines.push(
  681. 'Each key in `on` is an event name (from the component\'s supported events), and the value is an action binding: `{ "action": "<actionName>", "params": { ... } }`.',
  682. );
  683. lines.push("");
  684. lines.push("Example:");
  685. lines.push(
  686. ` ${JSON.stringify({ type: comp1, props: comp1Props, on: { press: { action: "setState", params: { path: "/saved", value: true } } }, children: [] })}`,
  687. );
  688. lines.push("");
  689. lines.push(
  690. 'Action params can use dynamic path references to read from state: { "path": "/statePath" }.',
  691. );
  692. lines.push(
  693. "IMPORTANT: Do NOT put action/actionParams inside props. Always use the `on` field for event bindings.",
  694. );
  695. lines.push("");
  696. // Visibility conditions
  697. lines.push("VISIBILITY CONDITIONS:");
  698. lines.push(
  699. "Elements can have an optional `visible` field to conditionally show/hide based on data state. IMPORTANT: `visible` is a top-level field on the element object (sibling of type/props/children), NOT inside props.",
  700. );
  701. lines.push(
  702. `Correct: ${JSON.stringify({ type: comp1, props: comp1Props, visible: { eq: [{ path: "/tab" }, "home"] }, children: ["..."] })}`,
  703. );
  704. lines.push(
  705. '- `{ "eq": [{ "path": "/statePath" }, "value"] }` - visible when state at path equals value',
  706. );
  707. lines.push(
  708. '- `{ "neq": [{ "path": "/statePath" }, "value"] }` - visible when state at path does not equal value',
  709. );
  710. lines.push('- `{ "path": "/statePath" }` - visible when path is truthy');
  711. lines.push(
  712. '- `{ "and": [...] }`, `{ "or": [...] }`, `{ "not": {...} }` - combine conditions',
  713. );
  714. lines.push("- `true` / `false` - always visible/hidden");
  715. lines.push("");
  716. lines.push(
  717. "Use a component with on.press bound to setState to update state and drive visibility.",
  718. );
  719. lines.push(
  720. `Example: A ${comp1} with on: { "press": { "action": "setState", "params": { "path": "/activeTab", "value": "home" } } } sets state, then a container with visible: { "eq": [{ "path": "/activeTab" }, "home"] } shows only when that tab is active.`,
  721. );
  722. lines.push("");
  723. // Dynamic prop expressions
  724. lines.push("DYNAMIC PROPS:");
  725. lines.push(
  726. "Any prop value can be a dynamic expression that resolves based on state. Two forms are supported:",
  727. );
  728. lines.push("");
  729. lines.push(
  730. '1. State binding: `{ "$path": "/statePath" }` - resolves to the value at that state path.',
  731. );
  732. lines.push(
  733. ' Example: `"color": { "$path": "/theme/primary" }` reads the color from state.',
  734. );
  735. lines.push("");
  736. lines.push(
  737. '2. Conditional: `{ "$cond": <condition>, "$then": <value>, "$else": <value> }` - evaluates the condition (same syntax as visibility conditions) and picks the matching value.',
  738. );
  739. lines.push(
  740. ' Example: `"color": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "#007AFF", "$else": "#8E8E93" }`',
  741. );
  742. lines.push(
  743. ' Example: `"name": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "home", "$else": "home-outline" }`',
  744. );
  745. lines.push("");
  746. lines.push(
  747. "Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ.",
  748. );
  749. lines.push("");
  750. // Rules
  751. lines.push("RULES:");
  752. const baseRules = [
  753. "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
  754. 'First set root: {"op":"add","path":"/root","value":"<root-key>"}',
  755. 'Then add each element: {"op":"add","path":"/elements/<key>","value":{...}}',
  756. "Output /state patches right after the elements that use them, one per array item for progressive loading. REQUIRED whenever using $path, repeat, or statePath.",
  757. "ONLY use components listed above",
  758. "Each element value needs: type, props, children (array of child keys)",
  759. "Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",
  760. ];
  761. const schemaRules = catalog.schema.defaultRules ?? [];
  762. const allRules = [...baseRules, ...schemaRules, ...customRules];
  763. allRules.forEach((rule, i) => {
  764. lines.push(`${i + 1}. ${rule}`);
  765. });
  766. return lines.join("\n");
  767. }
  768. // =============================================================================
  769. // Example Value Generation from Zod Schemas
  770. // =============================================================================
  771. /**
  772. * Component definition shape as it appears in catalog data
  773. */
  774. interface CatalogComponentDef {
  775. props?: z.ZodType;
  776. description?: string;
  777. slots?: string[];
  778. events?: string[];
  779. example?: Record<string, unknown>;
  780. }
  781. /**
  782. * Get example props for a catalog component.
  783. * Uses the explicit `example` field if provided, otherwise generates from Zod schema.
  784. */
  785. function getExampleProps(def: CatalogComponentDef): Record<string, unknown> {
  786. if (def.example && Object.keys(def.example).length > 0) {
  787. return def.example;
  788. }
  789. if (def.props) {
  790. return generateExamplePropsFromZod(def.props);
  791. }
  792. return {};
  793. }
  794. /**
  795. * Generate example prop values from a Zod object schema.
  796. * Only includes required fields to keep examples concise.
  797. */
  798. function generateExamplePropsFromZod(
  799. schema: z.ZodType,
  800. ): Record<string, unknown> {
  801. if (!schema || !schema._def) return {};
  802. const def = schema._def as unknown as Record<string, unknown>;
  803. const typeName = getZodTypeName(schema);
  804. if (typeName !== "ZodObject" && typeName !== "object") return {};
  805. const shape =
  806. typeof def.shape === "function"
  807. ? (def.shape as () => Record<string, z.ZodType>)()
  808. : (def.shape as Record<string, z.ZodType>);
  809. if (!shape) return {};
  810. const result: Record<string, unknown> = {};
  811. for (const [key, value] of Object.entries(shape)) {
  812. const innerTypeName = getZodTypeName(value);
  813. // Skip optional props to keep examples concise
  814. if (
  815. innerTypeName === "ZodOptional" ||
  816. innerTypeName === "optional" ||
  817. innerTypeName === "ZodNullable" ||
  818. innerTypeName === "nullable"
  819. ) {
  820. continue;
  821. }
  822. result[key] = generateExampleValue(value);
  823. }
  824. return result;
  825. }
  826. /**
  827. * Generate a single example value from a Zod type.
  828. */
  829. function generateExampleValue(schema: z.ZodType): unknown {
  830. if (!schema || !schema._def) return "...";
  831. const def = schema._def as unknown as Record<string, unknown>;
  832. const typeName = getZodTypeName(schema);
  833. switch (typeName) {
  834. case "ZodString":
  835. case "string":
  836. return "example";
  837. case "ZodNumber":
  838. case "number":
  839. return 0;
  840. case "ZodBoolean":
  841. case "boolean":
  842. return true;
  843. case "ZodLiteral":
  844. case "literal":
  845. return def.value;
  846. case "ZodEnum":
  847. case "enum": {
  848. if (Array.isArray(def.values) && def.values.length > 0)
  849. return def.values[0];
  850. if (def.entries && typeof def.entries === "object") {
  851. const values = Object.values(def.entries as Record<string, string>);
  852. return values.length > 0 ? values[0] : "example";
  853. }
  854. return "example";
  855. }
  856. case "ZodOptional":
  857. case "optional":
  858. case "ZodNullable":
  859. case "nullable":
  860. case "ZodDefault":
  861. case "default": {
  862. const inner = (def.innerType as z.ZodType) ?? (def.wrapped as z.ZodType);
  863. return inner ? generateExampleValue(inner) : null;
  864. }
  865. case "ZodArray":
  866. case "array":
  867. return [];
  868. case "ZodObject":
  869. case "object":
  870. return generateExamplePropsFromZod(schema);
  871. case "ZodUnion":
  872. case "union": {
  873. const options = def.options as z.ZodType[] | undefined;
  874. return options && options.length > 0
  875. ? generateExampleValue(options[0]!)
  876. : "...";
  877. }
  878. default:
  879. return "...";
  880. }
  881. }
  882. /**
  883. * Find the name of the first required string prop in a Zod object schema.
  884. * Used to demonstrate $path dynamic bindings in examples.
  885. */
  886. function findFirstStringProp(schema?: z.ZodType): string | null {
  887. if (!schema || !schema._def) return null;
  888. const def = schema._def as unknown as Record<string, unknown>;
  889. const typeName = getZodTypeName(schema);
  890. if (typeName !== "ZodObject" && typeName !== "object") return null;
  891. const shape =
  892. typeof def.shape === "function"
  893. ? (def.shape as () => Record<string, z.ZodType>)()
  894. : (def.shape as Record<string, z.ZodType>);
  895. if (!shape) return null;
  896. for (const [key, value] of Object.entries(shape)) {
  897. const innerTypeName = getZodTypeName(value);
  898. // Skip optional props
  899. if (
  900. innerTypeName === "ZodOptional" ||
  901. innerTypeName === "optional" ||
  902. innerTypeName === "ZodNullable" ||
  903. innerTypeName === "nullable"
  904. ) {
  905. continue;
  906. }
  907. // Unwrap to check the actual type
  908. if (innerTypeName === "ZodString" || innerTypeName === "string") {
  909. return key;
  910. }
  911. }
  912. return null;
  913. }
  914. // =============================================================================
  915. // Zod Introspection Helpers
  916. // =============================================================================
  917. /**
  918. * Get Zod type name from schema (handles different Zod versions)
  919. */
  920. function getZodTypeName(schema: z.ZodType): string {
  921. if (!schema || !schema._def) return "";
  922. const def = schema._def as unknown as Record<string, unknown>;
  923. // Zod 4+ uses _def.type, older versions use _def.typeName
  924. return (def.typeName as string) ?? (def.type as string) ?? "";
  925. }
  926. /**
  927. * Format a Zod type into a human-readable string
  928. */
  929. function formatZodType(schema: z.ZodType): string {
  930. if (!schema || !schema._def) return "unknown";
  931. const def = schema._def as unknown as Record<string, unknown>;
  932. const typeName = getZodTypeName(schema);
  933. switch (typeName) {
  934. case "ZodString":
  935. case "string":
  936. return "string";
  937. case "ZodNumber":
  938. case "number":
  939. return "number";
  940. case "ZodBoolean":
  941. case "boolean":
  942. return "boolean";
  943. case "ZodLiteral":
  944. case "literal":
  945. return JSON.stringify(def.value);
  946. case "ZodEnum":
  947. case "enum": {
  948. // Zod 3 uses values array, Zod 4 uses entries object
  949. let values: string[];
  950. if (Array.isArray(def.values)) {
  951. values = def.values as string[];
  952. } else if (def.entries && typeof def.entries === "object") {
  953. values = Object.values(def.entries as Record<string, string>);
  954. } else {
  955. return "enum";
  956. }
  957. return values.map((v) => `"${v}"`).join(" | ");
  958. }
  959. case "ZodArray":
  960. case "array": {
  961. const inner = (def.type as z.ZodType) ?? (def.element as z.ZodType);
  962. return inner ? `Array<${formatZodType(inner)}>` : "Array<unknown>";
  963. }
  964. case "ZodObject":
  965. case "object": {
  966. // Shape can be a function (Zod 3) or direct object (Zod 4)
  967. const shape =
  968. typeof def.shape === "function"
  969. ? (def.shape as () => Record<string, z.ZodType>)()
  970. : (def.shape as Record<string, z.ZodType>);
  971. if (!shape) return "object";
  972. const props = Object.entries(shape)
  973. .map(([key, value]) => {
  974. const innerTypeName = getZodTypeName(value);
  975. const isOptional =
  976. innerTypeName === "ZodOptional" ||
  977. innerTypeName === "ZodNullable" ||
  978. innerTypeName === "optional" ||
  979. innerTypeName === "nullable";
  980. return `${key}${isOptional ? "?" : ""}: ${formatZodType(value)}`;
  981. })
  982. .join(", ");
  983. return `{ ${props} }`;
  984. }
  985. case "ZodOptional":
  986. case "optional":
  987. case "ZodNullable":
  988. case "nullable": {
  989. const inner = (def.innerType as z.ZodType) ?? (def.wrapped as z.ZodType);
  990. return inner ? formatZodType(inner) : "unknown";
  991. }
  992. case "ZodUnion":
  993. case "union": {
  994. const options = def.options as z.ZodType[] | undefined;
  995. return options
  996. ? options.map((opt) => formatZodType(opt)).join(" | ")
  997. : "unknown";
  998. }
  999. default:
  1000. return "unknown";
  1001. }
  1002. }
  1003. /**
  1004. * Convert Zod schema to JSON Schema
  1005. */
  1006. function zodToJsonSchema(schema: z.ZodType): object {
  1007. // Simplified JSON Schema conversion
  1008. const def = schema._def as unknown as Record<string, unknown>;
  1009. const typeName = (def.typeName as string) ?? "";
  1010. switch (typeName) {
  1011. case "ZodString":
  1012. return { type: "string" };
  1013. case "ZodNumber":
  1014. return { type: "number" };
  1015. case "ZodBoolean":
  1016. return { type: "boolean" };
  1017. case "ZodLiteral":
  1018. return { const: def.value };
  1019. case "ZodEnum":
  1020. return { enum: def.values };
  1021. case "ZodArray": {
  1022. const inner = def.type as z.ZodType | undefined;
  1023. return {
  1024. type: "array",
  1025. items: inner ? zodToJsonSchema(inner) : {},
  1026. };
  1027. }
  1028. case "ZodObject": {
  1029. const shape = (def.shape as () => Record<string, z.ZodType>)?.();
  1030. if (!shape) return { type: "object" };
  1031. const properties: Record<string, object> = {};
  1032. const required: string[] = [];
  1033. for (const [key, value] of Object.entries(shape)) {
  1034. properties[key] = zodToJsonSchema(value);
  1035. const innerDef = value._def as unknown as Record<string, unknown>;
  1036. if (
  1037. innerDef.typeName !== "ZodOptional" &&
  1038. innerDef.typeName !== "ZodNullable"
  1039. ) {
  1040. required.push(key);
  1041. }
  1042. }
  1043. return {
  1044. type: "object",
  1045. properties,
  1046. required: required.length > 0 ? required : undefined,
  1047. additionalProperties: false,
  1048. };
  1049. }
  1050. case "ZodRecord": {
  1051. const valueType = def.valueType as z.ZodType | undefined;
  1052. return {
  1053. type: "object",
  1054. additionalProperties: valueType ? zodToJsonSchema(valueType) : true,
  1055. };
  1056. }
  1057. case "ZodOptional":
  1058. case "ZodNullable": {
  1059. const inner = def.innerType as z.ZodType | undefined;
  1060. return inner ? zodToJsonSchema(inner) : {};
  1061. }
  1062. case "ZodUnion": {
  1063. const options = def.options as z.ZodType[] | undefined;
  1064. return options ? { anyOf: options.map(zodToJsonSchema) } : {};
  1065. }
  1066. case "ZodAny":
  1067. return {};
  1068. default:
  1069. return {};
  1070. }
  1071. }
  1072. /**
  1073. * Shorthand: Define a catalog directly from a schema
  1074. */
  1075. export function defineCatalog<
  1076. TDef extends SchemaDefinition,
  1077. TCatalog extends InferCatalogInput<TDef["catalog"]>,
  1078. >(schema: Schema<TDef>, catalog: TCatalog): Catalog<TDef, TCatalog> {
  1079. return schema.createCatalog(catalog);
  1080. }