schema.ts 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523
  1. import { z } from "zod";
  2. import type { EditMode } from "./edit-modes";
  3. import { buildEditInstructions } from "./edit-modes";
  4. /**
  5. * Schema builder primitives
  6. */
  7. export interface SchemaBuilder {
  8. /** String type */
  9. string(): SchemaType<"string">;
  10. /** Number type */
  11. number(): SchemaType<"number">;
  12. /** Boolean type */
  13. boolean(): SchemaType<"boolean">;
  14. /** Array of type */
  15. array<T extends SchemaType>(item: T): SchemaType<"array", T>;
  16. /** Object with shape */
  17. object<T extends Record<string, SchemaType>>(
  18. shape: T,
  19. ): SchemaType<"object", T>;
  20. /** Record/map with value type */
  21. record<T extends SchemaType>(value: T): SchemaType<"record", T>;
  22. /** Any type */
  23. any(): SchemaType<"any">;
  24. /** Placeholder for user-provided Zod schema */
  25. zod(): SchemaType<"zod">;
  26. /** Reference to catalog key (e.g., 'catalog.components') */
  27. ref(path: string): SchemaType<"ref", string>;
  28. /** Props from referenced catalog entry */
  29. propsOf(path: string): SchemaType<"propsOf", string>;
  30. /** Map of named entries with shared shape */
  31. map<T extends Record<string, SchemaType>>(
  32. entryShape: T,
  33. ): SchemaType<"map", T>;
  34. /** Optional modifier */
  35. optional(): { optional: true };
  36. }
  37. /**
  38. * Schema type representation
  39. */
  40. export interface SchemaType<TKind extends string = string, TInner = unknown> {
  41. kind: TKind;
  42. inner?: TInner;
  43. optional?: boolean;
  44. }
  45. /**
  46. * Schema definition shape
  47. */
  48. export interface SchemaDefinition<
  49. TSpec extends SchemaType = SchemaType,
  50. TCatalog extends SchemaType = SchemaType,
  51. > {
  52. /** What the AI-generated spec looks like */
  53. spec: TSpec;
  54. /** What the catalog must provide */
  55. catalog: TCatalog;
  56. }
  57. /**
  58. * Schema instance with methods
  59. */
  60. export interface Schema<TDef extends SchemaDefinition = SchemaDefinition> {
  61. /** The schema definition */
  62. readonly definition: TDef;
  63. /** Custom prompt template for this schema */
  64. readonly promptTemplate?: PromptTemplate;
  65. /** Default rules baked into the schema (injected before customRules) */
  66. readonly defaultRules?: string[];
  67. /** Built-in actions always available at runtime (injected into prompts automatically) */
  68. readonly builtInActions?: BuiltInAction[];
  69. /** Create a catalog from this schema */
  70. createCatalog<TCatalog extends InferCatalogInput<TDef["catalog"]>>(
  71. catalog: TCatalog,
  72. ): Catalog<TDef, TCatalog>;
  73. }
  74. /**
  75. * Catalog instance with methods
  76. */
  77. export interface Catalog<
  78. TDef extends SchemaDefinition = SchemaDefinition,
  79. TCatalog = unknown,
  80. > {
  81. /** The schema this catalog is based on */
  82. readonly schema: Schema<TDef>;
  83. /** The catalog data */
  84. readonly data: TCatalog;
  85. /** Component names */
  86. readonly componentNames: string[];
  87. /** Action names */
  88. readonly actionNames: string[];
  89. /** Generate system prompt for AI */
  90. prompt(options?: PromptOptions): string;
  91. /** Export as JSON Schema for structured outputs */
  92. jsonSchema(options?: JsonSchemaOptions): object;
  93. /** Validate a spec against this catalog */
  94. validate(spec: unknown): SpecValidationResult<InferSpec<TDef, TCatalog>>;
  95. /** Get the Zod schema for the spec */
  96. zodSchema(): z.ZodType<InferSpec<TDef, TCatalog>>;
  97. /** Type helper for the spec type */
  98. readonly _specType: InferSpec<TDef, TCatalog>;
  99. }
  100. /**
  101. * Options for JSON Schema export
  102. */
  103. export interface JsonSchemaOptions {
  104. /**
  105. * When true, produces a strict JSON Schema subset compatible with
  106. * LLM structured output APIs (OpenAI, Google Gemini, Anthropic, etc.).
  107. * This ensures:
  108. * - `additionalProperties: false` on every object
  109. * - All object properties listed in `required` (optionals use nullable types)
  110. * - Record/map types converted to fixed-key objects
  111. *
  112. * **Limitation:** Record types (dynamic-key maps) cannot be represented in
  113. * strict JSON Schema because `additionalProperties` must be `false`. They
  114. * are emitted as `{ type: "object", properties: {}, additionalProperties: false }`.
  115. * The LLM prompt (via `catalog.prompt()`) still describes the full structure,
  116. * so the model can produce valid output even though the JSON Schema for
  117. * record entries is opaque.
  118. */
  119. strict?: boolean;
  120. }
  121. /**
  122. * Prompt generation options
  123. */
  124. export interface PromptOptions {
  125. /** Custom system message intro */
  126. system?: string;
  127. /** Additional rules to append */
  128. customRules?: string[];
  129. /**
  130. * Output mode for the generated prompt.
  131. *
  132. * - `"standalone"` (default): The LLM should output only JSONL patches (no prose).
  133. * - `"inline"`: The LLM should respond conversationally first, then output JSONL patches.
  134. * Includes rules about interleaving text with JSONL and not wrapping in code fences.
  135. *
  136. * @deprecated `"generate"` — use `"standalone"` instead.
  137. * @deprecated `"chat"` — use `"inline"` instead.
  138. */
  139. mode?: "standalone" | "inline" | "generate" | "chat";
  140. /** Edit modes to document in the system prompt. Default: `["patch"]`. */
  141. editModes?: EditMode[];
  142. }
  143. /**
  144. * Context provided to prompt templates
  145. */
  146. export interface PromptContext<TCatalog = unknown> {
  147. /** The catalog data */
  148. catalog: TCatalog;
  149. /** Component names from the catalog */
  150. componentNames: string[];
  151. /** Action names from the catalog (if any) */
  152. actionNames: string[];
  153. /** Prompt options provided by the user */
  154. options: PromptOptions;
  155. /** Helper to format a Zod type as a human-readable string */
  156. formatZodType: (schema: z.ZodType) => string;
  157. }
  158. /**
  159. * Prompt template function type
  160. */
  161. export type PromptTemplate<TCatalog = unknown> = (
  162. context: PromptContext<TCatalog>,
  163. ) => string;
  164. /**
  165. * A built-in action that is always available regardless of catalog configuration.
  166. * These are handled by the runtime (e.g. ActionProvider) and injected into prompts
  167. * automatically so the LLM knows about them.
  168. */
  169. export interface BuiltInAction {
  170. /** Action name (e.g. "setState") */
  171. name: string;
  172. /** Human-readable description for the LLM */
  173. description: string;
  174. }
  175. /**
  176. * Schema options
  177. */
  178. export interface SchemaOptions<TCatalog = unknown> {
  179. /** Custom prompt template for this schema */
  180. promptTemplate?: PromptTemplate<TCatalog>;
  181. /** Default rules baked into the schema (injected before customRules in prompts) */
  182. defaultRules?: string[];
  183. /**
  184. * Built-in actions that are always available regardless of catalog configuration.
  185. * These are injected into prompts automatically so the LLM knows about them,
  186. * but they don't require handlers in defineRegistry because the runtime
  187. * (e.g. ActionProvider) handles them directly.
  188. */
  189. builtInActions?: BuiltInAction[];
  190. }
  191. /**
  192. * Spec validation result
  193. */
  194. export interface SpecValidationResult<T> {
  195. success: boolean;
  196. data?: T;
  197. error?: z.ZodError;
  198. }
  199. // =============================================================================
  200. // Catalog Type Inference Helpers
  201. // =============================================================================
  202. /**
  203. * Extract the components map type from a catalog
  204. * @example type Components = InferCatalogComponents<typeof myCatalog>;
  205. */
  206. export type InferCatalogComponents<C extends Catalog> =
  207. C extends Catalog<SchemaDefinition, infer TCatalog>
  208. ? TCatalog extends { components: infer Comps }
  209. ? Comps
  210. : never
  211. : never;
  212. /**
  213. * Extract the actions map type from a catalog
  214. * @example type Actions = InferCatalogActions<typeof myCatalog>;
  215. */
  216. export type InferCatalogActions<C extends Catalog> =
  217. C extends Catalog<SchemaDefinition, infer TCatalog>
  218. ? TCatalog extends { actions: infer Acts }
  219. ? Acts
  220. : never
  221. : never;
  222. /**
  223. * Infer component props from a catalog by component name
  224. * @example type ButtonProps = InferComponentProps<typeof myCatalog, 'Button'>;
  225. */
  226. export type InferComponentProps<
  227. C extends Catalog,
  228. K extends keyof InferCatalogComponents<C>,
  229. > = InferCatalogComponents<C>[K] extends { props: z.ZodType<infer P> }
  230. ? P
  231. : never;
  232. /**
  233. * Infer action params from a catalog by action name
  234. * @example type ViewCustomersParams = InferActionParams<typeof myCatalog, 'viewCustomers'>;
  235. */
  236. export type InferActionParams<
  237. C extends Catalog,
  238. K extends keyof InferCatalogActions<C>,
  239. > = InferCatalogActions<C>[K] extends { params: z.ZodType<infer P> }
  240. ? P
  241. : never;
  242. // =============================================================================
  243. // Internal Type Inference Helpers
  244. // =============================================================================
  245. export type InferCatalogInput<T> =
  246. T extends SchemaType<"object", infer Shape>
  247. ? { [K in keyof Shape]: InferCatalogField<Shape[K]> }
  248. : never;
  249. type InferCatalogField<T> =
  250. T extends SchemaType<"map", infer EntryShape>
  251. ? Record<
  252. string,
  253. // Only 'props' is required, rest are optional
  254. InferMapEntryRequired<EntryShape> &
  255. Partial<InferMapEntryOptional<EntryShape>>
  256. >
  257. : T extends SchemaType<"zod">
  258. ? z.ZodType
  259. : T extends SchemaType<"string">
  260. ? string
  261. : T extends SchemaType<"number">
  262. ? number
  263. : T extends SchemaType<"boolean">
  264. ? boolean
  265. : T extends SchemaType<"array", infer Item>
  266. ? InferCatalogField<Item>[]
  267. : T extends SchemaType<"object", infer Shape>
  268. ? { [K in keyof Shape]: InferCatalogField<Shape[K]> }
  269. : unknown;
  270. // Extract required fields (props is always required)
  271. type InferMapEntryRequired<T> = {
  272. [K in keyof T as K extends "props" ? K : never]: InferMapEntryField<T[K]>;
  273. };
  274. // Extract optional fields (everything except props)
  275. type InferMapEntryOptional<T> = {
  276. [K in keyof T as K extends "props" ? never : K]: InferMapEntryField<T[K]>;
  277. };
  278. type InferMapEntryField<T> =
  279. T extends SchemaType<"zod">
  280. ? z.ZodType
  281. : T extends SchemaType<"string">
  282. ? string
  283. : T extends SchemaType<"number">
  284. ? number
  285. : T extends SchemaType<"boolean">
  286. ? boolean
  287. : T extends SchemaType<"array", infer Item>
  288. ? InferMapEntryField<Item>[]
  289. : T extends SchemaType<"object", infer Shape>
  290. ? { [K in keyof Shape]: InferMapEntryField<Shape[K]> }
  291. : unknown;
  292. // Spec inference (simplified - will be expanded)
  293. export type InferSpec<TDef extends SchemaDefinition, TCatalog> = TDef extends {
  294. spec: SchemaType<"object", infer Shape>;
  295. }
  296. ? InferSpecObject<Shape, TCatalog>
  297. : unknown;
  298. type InferSpecObject<Shape, TCatalog> = {
  299. [K in keyof Shape]: InferSpecField<Shape[K], TCatalog>;
  300. };
  301. type InferSpecField<T, TCatalog> =
  302. T extends SchemaType<"string">
  303. ? string
  304. : T extends SchemaType<"number">
  305. ? number
  306. : T extends SchemaType<"boolean">
  307. ? boolean
  308. : T extends SchemaType<"array", infer Item>
  309. ? InferSpecField<Item, TCatalog>[]
  310. : T extends SchemaType<"object", infer Shape>
  311. ? InferSpecObject<Shape, TCatalog>
  312. : T extends SchemaType<"record", infer Value>
  313. ? Record<string, InferSpecField<Value, TCatalog>>
  314. : T extends SchemaType<"ref", infer Path>
  315. ? InferRefType<Path, TCatalog>
  316. : T extends SchemaType<"propsOf", infer Path>
  317. ? InferPropsOfType<Path, TCatalog>
  318. : T extends SchemaType<"any">
  319. ? unknown
  320. : unknown;
  321. type InferRefType<Path, TCatalog> = Path extends "catalog.components"
  322. ? TCatalog extends { components: infer C }
  323. ? keyof C
  324. : string
  325. : Path extends "catalog.actions"
  326. ? TCatalog extends { actions: infer A }
  327. ? keyof A
  328. : string
  329. : string;
  330. type InferPropsOfType<Path, TCatalog> = Path extends "catalog.components"
  331. ? TCatalog extends { components: infer C }
  332. ? C extends Record<string, { props: z.ZodType<infer P> }>
  333. ? P
  334. : Record<string, unknown>
  335. : Record<string, unknown>
  336. : Record<string, unknown>;
  337. /**
  338. * Create the schema builder
  339. */
  340. function createBuilder(): SchemaBuilder {
  341. return {
  342. string: () => ({ kind: "string" }),
  343. number: () => ({ kind: "number" }),
  344. boolean: () => ({ kind: "boolean" }),
  345. array: (item) => ({ kind: "array", inner: item }),
  346. object: (shape) => ({ kind: "object", inner: shape }),
  347. record: (value) => ({ kind: "record", inner: value }),
  348. any: () => ({ kind: "any" }),
  349. zod: () => ({ kind: "zod" }),
  350. ref: (path) => ({ kind: "ref", inner: path }),
  351. propsOf: (path) => ({ kind: "propsOf", inner: path }),
  352. map: (entryShape) => ({ kind: "map", inner: entryShape }),
  353. optional: () => ({ optional: true }),
  354. };
  355. }
  356. /**
  357. * Define a schema using the builder pattern
  358. */
  359. export function defineSchema<TDef extends SchemaDefinition>(
  360. builder: (s: SchemaBuilder) => TDef,
  361. options?: SchemaOptions,
  362. ): Schema<TDef> {
  363. const s = createBuilder();
  364. const definition = builder(s);
  365. return {
  366. definition,
  367. promptTemplate: options?.promptTemplate,
  368. defaultRules: options?.defaultRules,
  369. builtInActions: options?.builtInActions,
  370. createCatalog<TCatalog extends InferCatalogInput<TDef["catalog"]>>(
  371. catalog: TCatalog,
  372. ): Catalog<TDef, TCatalog> {
  373. return createCatalogFromSchema(this as Schema<TDef>, catalog);
  374. },
  375. };
  376. }
  377. /**
  378. * Create a catalog from a schema (internal)
  379. */
  380. function createCatalogFromSchema<TDef extends SchemaDefinition, TCatalog>(
  381. schema: Schema<TDef>,
  382. catalogData: TCatalog,
  383. ): Catalog<TDef, TCatalog> {
  384. // Extract component and action names
  385. const components = (catalogData as Record<string, unknown>).components as
  386. | Record<string, unknown>
  387. | undefined;
  388. const actions = (catalogData as Record<string, unknown>).actions as
  389. | Record<string, unknown>
  390. | undefined;
  391. const componentNames = components ? Object.keys(components) : [];
  392. const actionNames = actions ? Object.keys(actions) : [];
  393. // Build the Zod schema for validation
  394. const zodSchema = buildZodSchemaFromDefinition(
  395. schema.definition,
  396. catalogData,
  397. );
  398. return {
  399. schema,
  400. data: catalogData,
  401. componentNames,
  402. actionNames,
  403. prompt(options: PromptOptions = {}): string {
  404. return generatePrompt(this, options);
  405. },
  406. jsonSchema(options: JsonSchemaOptions = {}): object {
  407. return zodToJsonSchema(zodSchema, options.strict ?? false);
  408. },
  409. validate(spec: unknown): SpecValidationResult<InferSpec<TDef, TCatalog>> {
  410. const result = zodSchema.safeParse(spec);
  411. if (result.success) {
  412. return {
  413. success: true,
  414. data: result.data as InferSpec<TDef, TCatalog>,
  415. };
  416. }
  417. return { success: false, error: result.error };
  418. },
  419. zodSchema(): z.ZodType<InferSpec<TDef, TCatalog>> {
  420. return zodSchema as z.ZodType<InferSpec<TDef, TCatalog>>;
  421. },
  422. get _specType(): InferSpec<TDef, TCatalog> {
  423. throw new Error("_specType is only for type inference");
  424. },
  425. };
  426. }
  427. /**
  428. * Build Zod schema from schema definition
  429. */
  430. function buildZodSchemaFromDefinition(
  431. definition: SchemaDefinition,
  432. catalogData: unknown,
  433. ): z.ZodType {
  434. return buildZodType(definition.spec, catalogData);
  435. }
  436. function buildZodType(schemaType: SchemaType, catalogData: unknown): z.ZodType {
  437. switch (schemaType.kind) {
  438. case "string":
  439. return z.string();
  440. case "number":
  441. return z.number();
  442. case "boolean":
  443. return z.boolean();
  444. case "any":
  445. return z.any();
  446. case "array": {
  447. const inner = buildZodType(schemaType.inner as SchemaType, catalogData);
  448. return z.array(inner);
  449. }
  450. case "object": {
  451. const shape = schemaType.inner as Record<string, SchemaType>;
  452. const zodShape: Record<string, z.ZodType> = {};
  453. for (const [key, value] of Object.entries(shape)) {
  454. let zodType = buildZodType(value, catalogData);
  455. if (value.optional) {
  456. zodType = zodType.optional();
  457. }
  458. zodShape[key] = zodType;
  459. }
  460. return z.object(zodShape);
  461. }
  462. case "record": {
  463. const inner = buildZodType(schemaType.inner as SchemaType, catalogData);
  464. return z.record(z.string(), inner);
  465. }
  466. case "ref": {
  467. // Reference to catalog key - create enum of valid keys
  468. const path = schemaType.inner as string;
  469. const keys = getKeysFromPath(path, catalogData);
  470. if (keys.length === 0) {
  471. return z.string();
  472. }
  473. if (keys.length === 1) {
  474. return z.literal(keys[0]!);
  475. }
  476. return z.enum(keys as [string, ...string[]]);
  477. }
  478. case "propsOf": {
  479. // Props from catalog entry - create union of all props schemas
  480. const path = schemaType.inner as string;
  481. const propsSchemas = getPropsFromPath(path, catalogData);
  482. if (propsSchemas.length === 0) {
  483. return z.record(z.string(), z.unknown());
  484. }
  485. if (propsSchemas.length === 1) {
  486. return propsSchemas[0]!;
  487. }
  488. // For propsOf, we need to be lenient since type determines which props apply
  489. return z.record(z.string(), z.unknown());
  490. }
  491. default:
  492. return z.unknown();
  493. }
  494. }
  495. function getKeysFromPath(path: string, catalogData: unknown): string[] {
  496. const parts = path.split(".");
  497. let current: unknown = { catalog: catalogData };
  498. for (const part of parts) {
  499. if (current && typeof current === "object") {
  500. current = (current as Record<string, unknown>)[part];
  501. } else {
  502. return [];
  503. }
  504. }
  505. if (current && typeof current === "object") {
  506. return Object.keys(current);
  507. }
  508. return [];
  509. }
  510. function getPropsFromPath(path: string, catalogData: unknown): z.ZodType[] {
  511. const parts = path.split(".");
  512. let current: unknown = { catalog: catalogData };
  513. for (const part of parts) {
  514. if (current && typeof current === "object") {
  515. current = (current as Record<string, unknown>)[part];
  516. } else {
  517. return [];
  518. }
  519. }
  520. if (current && typeof current === "object") {
  521. return Object.values(current as Record<string, { props?: z.ZodType }>)
  522. .map((entry) => entry.props)
  523. .filter((props): props is z.ZodType => props !== undefined);
  524. }
  525. return [];
  526. }
  527. /**
  528. * Generate system prompt from catalog
  529. */
  530. function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
  531. catalog: Catalog<TDef, TCatalog>,
  532. options: PromptOptions,
  533. ): string {
  534. // Check if schema has a custom prompt template
  535. if (catalog.schema.promptTemplate) {
  536. const context: PromptContext<TCatalog> = {
  537. catalog: catalog.data,
  538. componentNames: catalog.componentNames,
  539. actionNames: catalog.actionNames,
  540. options,
  541. formatZodType,
  542. };
  543. return catalog.schema.promptTemplate(context);
  544. }
  545. // Default JSONL element-tree format (for @json-render/react and similar)
  546. const {
  547. system = "You are a UI generator that outputs JSON.",
  548. customRules = [],
  549. mode: rawMode = "standalone",
  550. } = options;
  551. const mode: "standalone" | "inline" =
  552. rawMode === "chat"
  553. ? (console.warn(
  554. '[json-render] mode "chat" is deprecated, use "inline" instead',
  555. ),
  556. "inline")
  557. : rawMode === "generate"
  558. ? (console.warn(
  559. '[json-render] mode "generate" is deprecated, use "standalone" instead',
  560. ),
  561. "standalone")
  562. : rawMode;
  563. const lines: string[] = [];
  564. lines.push(system);
  565. lines.push("");
  566. // Output format section - explain JSONL streaming patch format
  567. if (mode === "inline") {
  568. lines.push("OUTPUT FORMAT (text + JSONL, RFC 6902 JSON Patch):");
  569. lines.push(
  570. "You respond conversationally. When generating UI, first write a brief explanation (1-3 sentences), then output JSONL patch lines wrapped in a ```spec code fence.",
  571. );
  572. lines.push(
  573. "The JSONL lines use RFC 6902 JSON Patch operations to build a UI tree. Always wrap them in a ```spec fence block:",
  574. );
  575. lines.push(" ```spec");
  576. lines.push(' {"op":"add","path":"/root","value":"main"}');
  577. lines.push(
  578. ' {"op":"add","path":"/elements/main","value":{"type":"Card","props":{"title":"Hello"},"children":[]}}',
  579. );
  580. lines.push(" ```");
  581. lines.push(
  582. "If the user's message does not require a UI (e.g. a greeting or clarifying question), respond with text only — no JSONL.",
  583. );
  584. } else {
  585. lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):");
  586. lines.push(
  587. "Output JSONL (one JSON object per line) using RFC 6902 JSON Patch operations to build a UI tree.",
  588. );
  589. }
  590. lines.push(
  591. "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.",
  592. );
  593. lines.push("");
  594. lines.push("Example output (each line is a separate JSON object):");
  595. lines.push("");
  596. // Build example using actual catalog component names and props to avoid hallucinations
  597. const allComponents = (catalog.data as Record<string, unknown>).components as
  598. | Record<string, CatalogComponentDef>
  599. | undefined;
  600. const cn = catalog.componentNames;
  601. const comp1 = cn[0] || "Component";
  602. const comp2 = cn.length > 1 ? cn[1]! : comp1;
  603. const comp1Def = allComponents?.[comp1];
  604. const comp2Def = allComponents?.[comp2];
  605. const comp1Props = comp1Def ? getExampleProps(comp1Def) : {};
  606. const comp2Props = comp2Def ? getExampleProps(comp2Def) : {};
  607. // Find a string prop on comp2 to demonstrate $state dynamic bindings
  608. const dynamicPropName = comp2Def?.props
  609. ? findFirstStringProp(comp2Def.props)
  610. : null;
  611. const dynamicProps = dynamicPropName
  612. ? { ...comp2Props, [dynamicPropName]: { $item: "title" } }
  613. : comp2Props;
  614. const exampleOutput = [
  615. JSON.stringify({ op: "add", path: "/root", value: "main" }),
  616. JSON.stringify({
  617. op: "add",
  618. path: "/elements/main",
  619. value: {
  620. type: comp1,
  621. props: comp1Props,
  622. children: ["child-1", "list"],
  623. },
  624. }),
  625. JSON.stringify({
  626. op: "add",
  627. path: "/elements/child-1",
  628. value: { type: comp2, props: comp2Props, children: [] },
  629. }),
  630. JSON.stringify({
  631. op: "add",
  632. path: "/elements/list",
  633. value: {
  634. type: comp1,
  635. props: comp1Props,
  636. repeat: { statePath: "/items", key: "id" },
  637. children: ["item"],
  638. },
  639. }),
  640. JSON.stringify({
  641. op: "add",
  642. path: "/elements/item",
  643. value: { type: comp2, props: dynamicProps, children: [] },
  644. }),
  645. JSON.stringify({ op: "add", path: "/state/items", value: [] }),
  646. JSON.stringify({
  647. op: "add",
  648. path: "/state/items/0",
  649. value: { id: "1", title: "First Item" },
  650. }),
  651. JSON.stringify({
  652. op: "add",
  653. path: "/state/items/1",
  654. value: { id: "2", title: "Second Item" },
  655. }),
  656. ].join("\n");
  657. lines.push(`${exampleOutput}
  658. 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.`);
  659. lines.push("");
  660. // Initial state section
  661. lines.push("INITIAL STATE:");
  662. lines.push(
  663. "Specs include a /state field to seed the state model. Components with { $bindState } or { $bindItem } read from and write to this state, and $state expressions read from it.",
  664. );
  665. lines.push(
  666. "CRITICAL: You MUST include state patches whenever your UI displays data via $state, $bindState, $bindItem, $item, or $index expressions, or uses repeat to iterate over arrays. Without state, these references resolve to nothing and repeat lists render zero items.",
  667. );
  668. lines.push(
  669. "Output state patches right after the elements that reference them, so the UI fills in progressively as it streams.",
  670. );
  671. lines.push(
  672. "Stream state progressively - output one patch per array item instead of one giant blob:",
  673. );
  674. lines.push(
  675. ' For arrays: {"op":"add","path":"/state/posts/0","value":{"id":"1","title":"First Post",...}} then /state/posts/1, /state/posts/2, etc.',
  676. );
  677. lines.push(
  678. ' For scalars: {"op":"add","path":"/state/newTodoText","value":""}',
  679. );
  680. lines.push(
  681. ' Initialize the array first if needed: {"op":"add","path":"/state/posts","value":[]}',
  682. );
  683. lines.push(
  684. 'When content comes from the state model, use { "$state": "/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.',
  685. );
  686. lines.push(
  687. "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.",
  688. );
  689. lines.push("");
  690. lines.push("DYNAMIC LISTS (repeat field):");
  691. lines.push(
  692. 'Any element can have a top-level "repeat" field to render its children once per item in a state array: { "repeat": { "statePath": "/arrayPath", "key": "id" } }.',
  693. );
  694. lines.push(
  695. 'The element itself renders once (as the container), and its children are expanded once per array item. "statePath" is the state array path. "key" is an optional field name on each item for stable React keys.',
  696. );
  697. lines.push(
  698. `Example: ${JSON.stringify({ type: comp1, props: comp1Props, repeat: { statePath: "/todos", key: "id" }, children: ["todo-item"] })}`,
  699. );
  700. lines.push(
  701. 'Inside children of a repeated element, use { "$item": "field" } to read a field from the current item, and { "$index": true } to get the current array index. For two-way binding to an item field use { "$bindItem": "completed" } on the appropriate prop.',
  702. );
  703. lines.push(
  704. "ALWAYS use the repeat field for lists backed by state arrays. NEVER hardcode individual elements for each array item.",
  705. );
  706. lines.push(
  707. 'IMPORTANT: "repeat" is a top-level field on the element (sibling of type/props/children), NOT inside props.',
  708. );
  709. lines.push("");
  710. lines.push("ARRAY STATE ACTIONS:");
  711. lines.push(
  712. 'Use action "pushState" to append items to arrays. Params: { statePath: "/arrayPath", value: { ...item }, clearStatePath: "/inputPath" }.',
  713. );
  714. lines.push(
  715. 'Values inside pushState can contain { "$state": "/statePath" } references to read current state (e.g. the text from an input field).',
  716. );
  717. lines.push(
  718. 'Use "$id" inside a pushState value to auto-generate a unique ID.',
  719. );
  720. lines.push(
  721. 'Example: on: { "press": { "action": "pushState", "params": { "statePath": "/todos", "value": { "id": "$id", "title": { "$state": "/newTodoText" }, "completed": false }, "clearStatePath": "/newTodoText" } } }',
  722. );
  723. lines.push(
  724. 'Use action "removeState" to remove items from arrays by index. Params: { statePath: "/arrayPath", index: N }. Inside a repeated element\'s children, use { "$index": true } for the current item index. Action params support the same expressions as props: { "$item": "field" } resolves to the absolute state path, { "$index": true } resolves to the index number, and { "$state": "/path" } reads a value from state.',
  725. );
  726. lines.push(
  727. "For lists where users can add/remove items (todos, carts, etc.), use pushState and removeState instead of hardcoding with setState.",
  728. );
  729. lines.push("");
  730. lines.push(
  731. '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.',
  732. );
  733. lines.push("");
  734. // Components section — reuse the typed reference from example generation
  735. const components = allComponents;
  736. if (components) {
  737. lines.push(`AVAILABLE COMPONENTS (${catalog.componentNames.length}):`);
  738. lines.push("");
  739. for (const [name, def] of Object.entries(components)) {
  740. const propsStr = def.props ? formatZodType(def.props) : "{}";
  741. const hasChildren = def.slots && def.slots.length > 0;
  742. const childrenStr = hasChildren ? " [accepts children]" : "";
  743. const eventsStr =
  744. def.events && def.events.length > 0
  745. ? ` [events: ${def.events.join(", ")}]`
  746. : "";
  747. const descStr = def.description ? ` - ${def.description}` : "";
  748. lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`);
  749. }
  750. lines.push("");
  751. }
  752. // Actions section
  753. const actions = (catalog.data as Record<string, unknown>).actions as
  754. | Record<string, { params?: z.ZodType; description?: string }>
  755. | undefined;
  756. const builtInActions = catalog.schema.builtInActions ?? [];
  757. const hasCustomActions = actions && catalog.actionNames.length > 0;
  758. const hasBuiltInActions = builtInActions.length > 0;
  759. if (hasCustomActions || hasBuiltInActions) {
  760. lines.push("AVAILABLE ACTIONS:");
  761. lines.push("");
  762. // Built-in actions (handled by runtime, always available)
  763. for (const action of builtInActions) {
  764. lines.push(`- ${action.name}: ${action.description} [built-in]`);
  765. }
  766. // Custom actions (declared in catalog, require handlers)
  767. if (hasCustomActions) {
  768. for (const [name, def] of Object.entries(actions)) {
  769. lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
  770. }
  771. }
  772. lines.push("");
  773. }
  774. // Events section
  775. lines.push("EVENTS (the `on` field):");
  776. lines.push(
  777. "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.",
  778. );
  779. lines.push(
  780. 'Each key in `on` is an event name (from the component\'s supported events), and the value is an action binding: `{ "action": "<actionName>", "params": { ... } }`.',
  781. );
  782. lines.push("");
  783. lines.push("Example:");
  784. lines.push(
  785. ` ${JSON.stringify({ type: comp1, props: comp1Props, on: { press: { action: "setState", params: { statePath: "/saved", value: true } } }, children: [] })}`,
  786. );
  787. lines.push("");
  788. lines.push(
  789. 'Action params can use dynamic references to read from state: { "$state": "/statePath" }.',
  790. );
  791. lines.push(
  792. "IMPORTANT: Do NOT put action/actionParams inside props. Always use the `on` field for event bindings.",
  793. );
  794. lines.push("");
  795. // Visibility conditions
  796. lines.push("VISIBILITY CONDITIONS:");
  797. lines.push(
  798. "Elements can have an optional `visible` field to conditionally show/hide based on state. IMPORTANT: `visible` is a top-level field on the element object (sibling of type/props/children), NOT inside props.",
  799. );
  800. lines.push(
  801. `Correct: ${JSON.stringify({ type: comp1, props: comp1Props, visible: { $state: "/activeTab", eq: "home" }, children: ["..."] })}`,
  802. );
  803. lines.push(
  804. '- `{ "$state": "/path" }` - visible when state at path is truthy',
  805. );
  806. lines.push(
  807. '- `{ "$state": "/path", "not": true }` - visible when state at path is falsy',
  808. );
  809. lines.push(
  810. '- `{ "$state": "/path", "eq": "value" }` - visible when state equals value',
  811. );
  812. lines.push(
  813. '- `{ "$state": "/path", "neq": "value" }` - visible when state does not equal value',
  814. );
  815. lines.push(
  816. '- `{ "$state": "/path", "gt": N }` / `gte` / `lt` / `lte` - numeric comparisons',
  817. );
  818. lines.push(
  819. "- Use ONE operator per condition (eq, neq, gt, gte, lt, lte). Do not combine multiple operators.",
  820. );
  821. lines.push('- Any condition can add `"not": true` to invert its result');
  822. lines.push(
  823. "- `[condition, condition]` - all conditions must be true (implicit AND)",
  824. );
  825. lines.push(
  826. '- `{ "$and": [condition, condition] }` - explicit AND (use when nesting inside $or)',
  827. );
  828. lines.push(
  829. '- `{ "$or": [condition, condition] }` - at least one must be true (OR)',
  830. );
  831. lines.push("- `true` / `false` - always visible/hidden");
  832. lines.push("");
  833. lines.push(
  834. "Use a component with on.press bound to setState to update state and drive visibility.",
  835. );
  836. lines.push(
  837. `Example: A ${comp1} with on: { "press": { "action": "setState", "params": { "statePath": "/activeTab", "value": "home" } } } sets state, then a container with visible: { "$state": "/activeTab", "eq": "home" } shows only when that tab is active.`,
  838. );
  839. lines.push("");
  840. lines.push(
  841. 'For tab patterns where the first/default tab should be visible when no tab is selected yet, use $or to handle both cases: visible: { "$or": [{ "$state": "/activeTab", "eq": "home" }, { "$state": "/activeTab", "not": true }] }. This ensures the first tab is visible both when explicitly selected AND when /activeTab is not yet set.',
  842. );
  843. lines.push("");
  844. // Dynamic prop expressions
  845. lines.push("DYNAMIC PROPS:");
  846. lines.push(
  847. "Any prop value can be a dynamic expression that resolves based on state. Three forms are supported:",
  848. );
  849. lines.push("");
  850. lines.push(
  851. '1. Read-only state: `{ "$state": "/statePath" }` - resolves to the value at that state path (one-way read).',
  852. );
  853. lines.push(
  854. ' Example: `"color": { "$state": "/theme/primary" }` reads the color from state.',
  855. );
  856. lines.push("");
  857. lines.push(
  858. '2. Two-way binding: `{ "$bindState": "/statePath" }` - resolves to the value at the state path AND enables write-back. Use on form input props (value, checked, pressed, etc.).',
  859. );
  860. lines.push(
  861. ' Example: `"value": { "$bindState": "/form/email" }` binds the input value to /form/email.',
  862. );
  863. lines.push(
  864. ' Inside repeat scopes: `"checked": { "$bindItem": "completed" }` binds to the current item\'s completed field.',
  865. );
  866. lines.push("");
  867. lines.push(
  868. '3. Conditional: `{ "$cond": <condition>, "$then": <value>, "$else": <value> }` - evaluates the condition (same syntax as visibility conditions) and picks the matching value.',
  869. );
  870. lines.push(
  871. ' Example: `"color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }`',
  872. );
  873. lines.push("");
  874. lines.push(
  875. "Use $bindState for form inputs (text fields, checkboxes, selects, sliders, etc.) and $state for read-only data display. Inside repeat scopes, use $bindItem for form inputs bound to the current item. Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ.",
  876. );
  877. lines.push("");
  878. lines.push(
  879. '4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates references in the string. Absolute paths like `${/path}` resolve against the state model. Bare names like `${field}` resolve against the current repeat item first, then fall back to the state model at `/<field>`.',
  880. );
  881. lines.push(
  882. ' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00. Inside a repeat, `{ "$template": "${name} - ${email}" }` reads name and email from each item.',
  883. );
  884. lines.push("");
  885. // $computed section — only emit when catalog defines functions
  886. const catalogFunctions = (catalog.data as Record<string, unknown>).functions;
  887. if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
  888. lines.push(
  889. '5. Computed: `{ "$computed": "<functionName>", "args": { "key": <expression> } }` - calls a registered function with resolved args and returns the result.',
  890. );
  891. lines.push(
  892. ' Example: `"value": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } }`',
  893. );
  894. lines.push(" Available functions:");
  895. for (const name of Object.keys(
  896. catalogFunctions as Record<string, unknown>,
  897. )) {
  898. lines.push(` - ${name}`);
  899. }
  900. lines.push("");
  901. }
  902. // Validation section — only emit when at least one component has a `checks` prop
  903. const hasChecksComponents = allComponents
  904. ? Object.entries(allComponents).some(([, def]) => {
  905. if (!def.props) return false;
  906. const formatted = formatZodType(def.props);
  907. return formatted.includes("checks");
  908. })
  909. : false;
  910. if (hasChecksComponents) {
  911. lines.push("VALIDATION:");
  912. lines.push(
  913. "Form components that accept a `checks` prop support client-side validation.",
  914. );
  915. lines.push(
  916. 'Each check is an object: { "type": "<name>", "message": "...", "args": { ... } }',
  917. );
  918. lines.push("");
  919. lines.push("Built-in validation types:");
  920. lines.push(" - required — value must be non-empty");
  921. lines.push(" - email — valid email format");
  922. lines.push(' - minLength — minimum string length (args: { "min": N })');
  923. lines.push(' - maxLength — maximum string length (args: { "max": N })');
  924. lines.push(' - pattern — match a regex (args: { "pattern": "regex" })');
  925. lines.push(' - min — minimum numeric value (args: { "min": N })');
  926. lines.push(' - max — maximum numeric value (args: { "max": N })');
  927. lines.push(" - numeric — value must be a number");
  928. lines.push(" - url — valid URL format");
  929. lines.push(
  930. ' - matches — must equal another field (args: { "other": { "$state": "/path" } })',
  931. );
  932. lines.push(
  933. ' - equalTo — alias for matches (args: { "other": { "$state": "/path" } })',
  934. );
  935. lines.push(
  936. ' - lessThan — value must be less than another field (args: { "other": { "$state": "/path" } })',
  937. );
  938. lines.push(
  939. ' - greaterThan — value must be greater than another field (args: { "other": { "$state": "/path" } })',
  940. );
  941. lines.push(
  942. ' - requiredIf — required only when another field is truthy (args: { "field": { "$state": "/path" } })',
  943. );
  944. lines.push("");
  945. lines.push("Example:");
  946. lines.push(
  947. ' "checks": [{ "type": "required", "message": "Email is required" }, { "type": "email", "message": "Invalid email" }]',
  948. );
  949. lines.push("");
  950. lines.push(
  951. "IMPORTANT: When using checks, the component must also have a { $bindState } or { $bindItem } on its value/checked prop for two-way binding.",
  952. );
  953. lines.push(
  954. "Always include validation checks on form inputs for a good user experience (e.g. required, email, minLength).",
  955. );
  956. lines.push("");
  957. }
  958. // State watchers section — only emit when actions are available (watchers
  959. // trigger actions, so the section is irrelevant without them).
  960. if (hasCustomActions || hasBuiltInActions) {
  961. lines.push("STATE WATCHERS:");
  962. lines.push(
  963. "Elements can have an optional `watch` field to react to state changes and trigger actions. The `watch` field is a top-level field on the element (sibling of type/props/children), NOT inside props.",
  964. );
  965. lines.push(
  966. "Maps state paths (JSON Pointers) to action bindings. When the value at a watched path changes, the bound actions fire automatically.",
  967. );
  968. lines.push("");
  969. lines.push(
  970. "Example (cascading select — country changes trigger city loading):",
  971. );
  972. lines.push(
  973. ` ${JSON.stringify({ type: "Select", props: { value: { $bindState: "/form/country" }, options: ["US", "Canada", "UK"] }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } } } }, children: [] })}`,
  974. );
  975. lines.push("");
  976. lines.push(
  977. "Use `watch` for cascading dependencies where changing one field should trigger side effects (loading data, resetting dependent fields, computing derived values).",
  978. );
  979. lines.push(
  980. "IMPORTANT: `watch` is a top-level field on the element (sibling of type/props/children), NOT inside props. Watchers only fire when the value changes, not on initial render.",
  981. );
  982. lines.push("");
  983. }
  984. // Edit modes
  985. const editModes = options.editModes;
  986. if (editModes && editModes.length > 0) {
  987. lines.push(buildEditInstructions({ modes: editModes }, "json"));
  988. }
  989. // Rules
  990. lines.push("RULES:");
  991. const baseRules =
  992. mode === "inline"
  993. ? [
  994. "When generating UI, wrap all JSONL patches in a ```spec code fence - one JSON object per line inside the fence",
  995. "Write a brief conversational response before any JSONL output",
  996. 'First set root: {"op":"add","path":"/root","value":"<root-key>"}',
  997. 'Then add each element: {"op":"add","path":"/elements/<key>","value":{...}}',
  998. "Output /state patches right after the elements that use them, one per array item for progressive loading. REQUIRED whenever using $state, $bindState, $bindItem, $item, $index, or repeat.",
  999. "ONLY use components listed above",
  1000. "Each element value needs: type, props, children (array of child keys)",
  1001. "Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",
  1002. ]
  1003. : [
  1004. "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
  1005. 'First set root: {"op":"add","path":"/root","value":"<root-key>"}',
  1006. 'Then add each element: {"op":"add","path":"/elements/<key>","value":{...}}',
  1007. "Output /state patches right after the elements that use them, one per array item for progressive loading. REQUIRED whenever using $state, $bindState, $bindItem, $item, $index, or repeat.",
  1008. "ONLY use components listed above",
  1009. "Each element value needs: type, props, children (array of child keys)",
  1010. "Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",
  1011. ];
  1012. const schemaRules = catalog.schema.defaultRules ?? [];
  1013. const allRules = [...baseRules, ...schemaRules, ...customRules];
  1014. allRules.forEach((rule, i) => {
  1015. lines.push(`${i + 1}. ${rule}`);
  1016. });
  1017. return lines.join("\n");
  1018. }
  1019. // =============================================================================
  1020. // Example Value Generation from Zod Schemas
  1021. // =============================================================================
  1022. /**
  1023. * Component definition shape as it appears in catalog data
  1024. */
  1025. interface CatalogComponentDef {
  1026. props?: z.ZodType;
  1027. description?: string;
  1028. slots?: string[];
  1029. events?: string[];
  1030. example?: Record<string, unknown>;
  1031. }
  1032. /**
  1033. * Get example props for a catalog component.
  1034. * Uses the explicit `example` field if provided, otherwise generates from Zod schema.
  1035. */
  1036. function getExampleProps(def: CatalogComponentDef): Record<string, unknown> {
  1037. if (def.example && Object.keys(def.example).length > 0) {
  1038. return def.example;
  1039. }
  1040. if (def.props) {
  1041. return generateExamplePropsFromZod(def.props);
  1042. }
  1043. return {};
  1044. }
  1045. /**
  1046. * Generate example prop values from a Zod object schema.
  1047. * Only includes required fields to keep examples concise.
  1048. */
  1049. function generateExamplePropsFromZod(
  1050. schema: z.ZodType,
  1051. ): Record<string, unknown> {
  1052. if (!schema || !schema._def) return {};
  1053. const def = schema._def as unknown as Record<string, unknown>;
  1054. const typeName = getZodTypeName(schema);
  1055. if (typeName !== "ZodObject" && typeName !== "object") return {};
  1056. const shape =
  1057. typeof def.shape === "function"
  1058. ? (def.shape as () => Record<string, z.ZodType>)()
  1059. : (def.shape as Record<string, z.ZodType>);
  1060. if (!shape) return {};
  1061. const result: Record<string, unknown> = {};
  1062. for (const [key, value] of Object.entries(shape)) {
  1063. const innerTypeName = getZodTypeName(value);
  1064. // Skip optional props to keep examples concise
  1065. if (
  1066. innerTypeName === "ZodOptional" ||
  1067. innerTypeName === "optional" ||
  1068. innerTypeName === "ZodNullable" ||
  1069. innerTypeName === "nullable"
  1070. ) {
  1071. continue;
  1072. }
  1073. result[key] = generateExampleValue(value);
  1074. }
  1075. return result;
  1076. }
  1077. /**
  1078. * Generate a single example value from a Zod type.
  1079. */
  1080. function generateExampleValue(schema: z.ZodType): unknown {
  1081. if (!schema || !schema._def) return "...";
  1082. const def = schema._def as unknown as Record<string, unknown>;
  1083. const typeName = getZodTypeName(schema);
  1084. switch (typeName) {
  1085. case "ZodString":
  1086. case "string":
  1087. return "example";
  1088. case "ZodNumber":
  1089. case "number":
  1090. return 0;
  1091. case "ZodBoolean":
  1092. case "boolean":
  1093. return true;
  1094. case "ZodLiteral":
  1095. case "literal":
  1096. return def.value;
  1097. case "ZodEnum":
  1098. case "enum": {
  1099. if (Array.isArray(def.values) && def.values.length > 0)
  1100. return def.values[0];
  1101. if (def.entries && typeof def.entries === "object") {
  1102. const values = Object.values(def.entries as Record<string, string>);
  1103. return values.length > 0 ? values[0] : "example";
  1104. }
  1105. return "example";
  1106. }
  1107. case "ZodOptional":
  1108. case "optional":
  1109. case "ZodNullable":
  1110. case "nullable":
  1111. case "ZodDefault":
  1112. case "default": {
  1113. const inner = (def.innerType as z.ZodType) ?? (def.wrapped as z.ZodType);
  1114. return inner ? generateExampleValue(inner) : null;
  1115. }
  1116. case "ZodArray":
  1117. case "array":
  1118. return [];
  1119. case "ZodObject":
  1120. case "object":
  1121. return generateExamplePropsFromZod(schema);
  1122. case "ZodUnion":
  1123. case "union": {
  1124. const options = def.options as z.ZodType[] | undefined;
  1125. return options && options.length > 0
  1126. ? generateExampleValue(options[0]!)
  1127. : "...";
  1128. }
  1129. default:
  1130. return "...";
  1131. }
  1132. }
  1133. /**
  1134. * Find the name of the first required string prop in a Zod object schema.
  1135. * Used to demonstrate $state dynamic bindings in examples.
  1136. */
  1137. function findFirstStringProp(schema?: z.ZodType): string | null {
  1138. if (!schema || !schema._def) return null;
  1139. const def = schema._def as unknown as Record<string, unknown>;
  1140. const typeName = getZodTypeName(schema);
  1141. if (typeName !== "ZodObject" && typeName !== "object") return null;
  1142. const shape =
  1143. typeof def.shape === "function"
  1144. ? (def.shape as () => Record<string, z.ZodType>)()
  1145. : (def.shape as Record<string, z.ZodType>);
  1146. if (!shape) return null;
  1147. for (const [key, value] of Object.entries(shape)) {
  1148. const innerTypeName = getZodTypeName(value);
  1149. // Skip optional props
  1150. if (
  1151. innerTypeName === "ZodOptional" ||
  1152. innerTypeName === "optional" ||
  1153. innerTypeName === "ZodNullable" ||
  1154. innerTypeName === "nullable"
  1155. ) {
  1156. continue;
  1157. }
  1158. // Unwrap to check the actual type
  1159. if (innerTypeName === "ZodString" || innerTypeName === "string") {
  1160. return key;
  1161. }
  1162. }
  1163. return null;
  1164. }
  1165. // =============================================================================
  1166. // Zod Introspection Helpers
  1167. // =============================================================================
  1168. /**
  1169. * Get Zod type name from schema (handles different Zod versions)
  1170. */
  1171. function getZodTypeName(schema: z.ZodType): string {
  1172. if (!schema || !schema._def) return "";
  1173. const def = schema._def as unknown as Record<string, unknown>;
  1174. // Zod 4+ uses _def.type, older versions use _def.typeName
  1175. return (def.typeName as string) ?? (def.type as string) ?? "";
  1176. }
  1177. /**
  1178. * Format a Zod type into a human-readable string
  1179. */
  1180. function formatZodType(schema: z.ZodType): string {
  1181. if (!schema || !schema._def) return "unknown";
  1182. const def = schema._def as unknown as Record<string, unknown>;
  1183. const typeName = getZodTypeName(schema);
  1184. switch (typeName) {
  1185. case "ZodString":
  1186. case "string":
  1187. return "string";
  1188. case "ZodNumber":
  1189. case "number":
  1190. return "number";
  1191. case "ZodBoolean":
  1192. case "boolean":
  1193. return "boolean";
  1194. case "ZodLiteral":
  1195. case "literal":
  1196. return JSON.stringify(def.value);
  1197. case "ZodEnum":
  1198. case "enum": {
  1199. // Zod 3 uses values array, Zod 4 uses entries object
  1200. let values: string[];
  1201. if (Array.isArray(def.values)) {
  1202. values = def.values as string[];
  1203. } else if (def.entries && typeof def.entries === "object") {
  1204. values = Object.values(def.entries as Record<string, string>);
  1205. } else {
  1206. return "enum";
  1207. }
  1208. return values.map((v) => `"${v}"`).join(" | ");
  1209. }
  1210. case "ZodArray":
  1211. case "array": {
  1212. // safely resolve inner type for Zod arrays
  1213. const inner = (
  1214. typeof def.element === "object"
  1215. ? def.element
  1216. : typeof def.type === "object"
  1217. ? def.type
  1218. : undefined
  1219. ) as z.ZodType | undefined;
  1220. return inner ? `Array<${formatZodType(inner)}>` : "Array<unknown>";
  1221. }
  1222. case "ZodObject":
  1223. case "object": {
  1224. // Shape can be a function (Zod 3) or direct object (Zod 4)
  1225. const shape =
  1226. typeof def.shape === "function"
  1227. ? (def.shape as () => Record<string, z.ZodType>)()
  1228. : (def.shape as Record<string, z.ZodType>);
  1229. if (!shape) return "object";
  1230. const props = Object.entries(shape)
  1231. .map(([key, value]) => {
  1232. const innerTypeName = getZodTypeName(value);
  1233. const isOptional =
  1234. innerTypeName === "ZodOptional" ||
  1235. innerTypeName === "ZodNullable" ||
  1236. innerTypeName === "optional" ||
  1237. innerTypeName === "nullable";
  1238. return `${key}${isOptional ? "?" : ""}: ${formatZodType(value)}`;
  1239. })
  1240. .join(", ");
  1241. return `{ ${props} }`;
  1242. }
  1243. case "ZodOptional":
  1244. case "optional":
  1245. case "ZodNullable":
  1246. case "nullable": {
  1247. const inner = (def.innerType as z.ZodType) ?? (def.wrapped as z.ZodType);
  1248. return inner ? formatZodType(inner) : "unknown";
  1249. }
  1250. case "ZodUnion":
  1251. case "union": {
  1252. const options = def.options as z.ZodType[] | undefined;
  1253. return options
  1254. ? options.map((opt) => formatZodType(opt)).join(" | ")
  1255. : "unknown";
  1256. }
  1257. default:
  1258. return "unknown";
  1259. }
  1260. }
  1261. /**
  1262. * Resolve the Zod type name from a schema's internal definition.
  1263. * Supports both Zod 3 (`_def.typeName`) and Zod 4 (`_def.type`).
  1264. */
  1265. function zodTypeName(def: Record<string, unknown>): string {
  1266. // Zod 4 uses _def.type as a plain string (e.g. "string", "object")
  1267. if (typeof def.type === "string") return def.type;
  1268. // Zod 3 uses _def.typeName (e.g. "ZodString", "ZodObject")
  1269. if (typeof def.typeName === "string") return def.typeName;
  1270. return "";
  1271. }
  1272. /**
  1273. * Normalise a Zod type name to a canonical lowercase form.
  1274. * Handles both Zod 3 ("ZodString") and Zod 4 ("string") conventions.
  1275. */
  1276. function normalizeTypeName(raw: string): string {
  1277. // Zod 3 names start with "Zod", e.g. "ZodString" → "string"
  1278. if (raw.startsWith("Zod")) {
  1279. return raw.slice(3).toLowerCase();
  1280. }
  1281. return raw.toLowerCase();
  1282. }
  1283. /**
  1284. * Convert Zod schema to JSON Schema.
  1285. *
  1286. * When `strict` is true the output conforms to the JSON Schema subset required
  1287. * by LLM structured output APIs (no `propertyNames`, `additionalProperties: false`
  1288. * everywhere, all properties listed in `required`).
  1289. */
  1290. function zodToJsonSchema(schema: z.ZodType, strict = false): object {
  1291. const def = schema._def as unknown as Record<string, unknown>;
  1292. const kind = normalizeTypeName(zodTypeName(def));
  1293. switch (kind) {
  1294. case "string":
  1295. return { type: "string" };
  1296. case "number":
  1297. return { type: "number" };
  1298. case "boolean":
  1299. return { type: "boolean" };
  1300. case "literal": {
  1301. // Zod 4: _def.values (array), Zod 3: _def.value (single)
  1302. const values = def.values as unknown[] | undefined;
  1303. const value = values ? values[0] : def.value;
  1304. return { const: value };
  1305. }
  1306. case "enum": {
  1307. // Zod 4: _def.entries (object { a:"a", b:"b" }), Zod 3: _def.values (string[])
  1308. const entries = def.entries as Record<string, string> | undefined;
  1309. const values = entries
  1310. ? Object.values(entries)
  1311. : (def.values as string[] | undefined);
  1312. return { enum: values ?? [] };
  1313. }
  1314. case "array": {
  1315. // Zod 4: _def.element, Zod 3: _def.type
  1316. const inner = (def.element ?? def.type) as z.ZodType | undefined;
  1317. return {
  1318. type: "array",
  1319. items: inner ? zodToJsonSchema(inner, strict) : {},
  1320. };
  1321. }
  1322. case "object": {
  1323. // Zod 4: _def.shape is an object, Zod 3: _def.shape is a function
  1324. const rawShape = def.shape;
  1325. const shape: Record<string, z.ZodType> | undefined =
  1326. typeof rawShape === "function"
  1327. ? (rawShape as () => Record<string, z.ZodType>)()
  1328. : (rawShape as Record<string, z.ZodType> | undefined);
  1329. if (!shape) {
  1330. if (strict) {
  1331. return {
  1332. type: "object",
  1333. properties: {},
  1334. required: [],
  1335. additionalProperties: false,
  1336. };
  1337. }
  1338. return { type: "object" };
  1339. }
  1340. const properties: Record<string, object> = {};
  1341. const required: string[] = [];
  1342. for (const [key, value] of Object.entries(shape)) {
  1343. const innerDef = value._def as unknown as Record<string, unknown>;
  1344. const innerKind = normalizeTypeName(zodTypeName(innerDef));
  1345. const isOptional = innerKind === "optional" || innerKind === "nullable";
  1346. if (strict) {
  1347. // In strict mode, all properties must be in required.
  1348. // Optional properties are represented as nullable types.
  1349. required.push(key);
  1350. if (isOptional) {
  1351. const unwrapped = zodToJsonSchema(value, strict);
  1352. properties[key] = { anyOf: [unwrapped, { type: "null" }] };
  1353. } else {
  1354. properties[key] = zodToJsonSchema(value, strict);
  1355. }
  1356. } else {
  1357. properties[key] = zodToJsonSchema(value);
  1358. if (!isOptional) {
  1359. required.push(key);
  1360. }
  1361. }
  1362. }
  1363. return {
  1364. type: "object",
  1365. properties,
  1366. required: required.length > 0 ? required : undefined,
  1367. additionalProperties: false,
  1368. };
  1369. }
  1370. case "record": {
  1371. const valueType = def.valueType as z.ZodType | undefined;
  1372. if (strict) {
  1373. // LLM strict schemas require `additionalProperties: false` and do not
  1374. // permit a schema value for `additionalProperties`. Since record types
  1375. // have dynamic keys that cannot be enumerated at schema-generation time,
  1376. // we emit an opaque object. The LLM prompt still describes the expected
  1377. // structure so the model can produce valid output.
  1378. return {
  1379. type: "object",
  1380. properties: {},
  1381. required: [],
  1382. additionalProperties: false,
  1383. };
  1384. }
  1385. return {
  1386. type: "object",
  1387. additionalProperties: valueType ? zodToJsonSchema(valueType) : true,
  1388. };
  1389. }
  1390. case "optional":
  1391. case "nullable": {
  1392. const inner = def.innerType as z.ZodType | undefined;
  1393. return inner ? zodToJsonSchema(inner, strict) : {};
  1394. }
  1395. case "union": {
  1396. const options = def.options as z.ZodType[] | undefined;
  1397. return options
  1398. ? { anyOf: options.map((o) => zodToJsonSchema(o, strict)) }
  1399. : {};
  1400. }
  1401. case "any":
  1402. case "unknown":
  1403. if (strict) {
  1404. return {
  1405. type: "object",
  1406. properties: {},
  1407. required: [],
  1408. additionalProperties: false,
  1409. };
  1410. }
  1411. return {};
  1412. default:
  1413. return {};
  1414. }
  1415. }
  1416. /**
  1417. * Shorthand: Define a catalog directly from a schema
  1418. */
  1419. export function defineCatalog<
  1420. TDef extends SchemaDefinition,
  1421. TCatalog extends InferCatalogInput<TDef["catalog"]>,
  1422. >(schema: Schema<TDef>, catalog: TCatalog): Catalog<TDef, TCatalog> {
  1423. return schema.createCatalog(catalog);
  1424. }