schema.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import { defineSchema, type PromptContext } from "@json-render/core";
  2. /**
  3. * Prompt template for Next.js app generation.
  4. *
  5. * Teaches the AI to generate multi-page applications with routes,
  6. * layouts, metadata, and page content using JSONL patches.
  7. */
  8. function nextAppPromptTemplate(context: PromptContext): string {
  9. const { catalog, options, formatZodType } = context;
  10. const {
  11. system = "You are a Next.js application generator.",
  12. customRules = [],
  13. } = options;
  14. const lines: string[] = [];
  15. lines.push(system);
  16. lines.push("");
  17. lines.push("OUTPUT FORMAT:");
  18. lines.push(
  19. "Output JSONL (one JSON object per line) with RFC 6902 JSON Patch operations to build a Next.js application spec.",
  20. );
  21. lines.push(
  22. "The spec defines routes (pages), layouts, metadata, and state for a full Next.js app.",
  23. );
  24. lines.push("");
  25. lines.push("Example output (each line is a separate JSON object):");
  26. lines.push("");
  27. lines.push(
  28. `{"op":"add","path":"/metadata","value":{"title":{"default":"My App","template":"%s | My App"},"description":"A full Next.js application"}}`,
  29. );
  30. lines.push(`{"op":"add","path":"/layouts","value":{}}`);
  31. lines.push(
  32. `{"op":"add","path":"/layouts/main","value":{"root":"shell","elements":{"shell":{"type":"AppShell","props":{},"children":["nav","slot"]},"nav":{"type":"NavBar","props":{"links":[{"href":"/","label":"Home"},{"href":"/about","label":"About"}]},"children":[]},"slot":{"type":"Slot","props":{},"children":[]}}}}`,
  33. );
  34. lines.push(`{"op":"add","path":"/routes","value":{}}`);
  35. lines.push(
  36. `{"op":"add","path":"/routes/~1","value":{"layout":"main","metadata":{"title":"Home"},"page":{"root":"hero","elements":{"hero":{"type":"Card","props":{"title":"Welcome"},"children":[]}}}}}`,
  37. );
  38. lines.push(
  39. `{"op":"add","path":"/routes/~1about","value":{"layout":"main","metadata":{"title":"About"},"page":{"root":"content","elements":{"content":{"type":"Card","props":{"title":"About Us"},"children":[]}}}}}`,
  40. );
  41. lines.push("");
  42. lines.push("SPEC STRUCTURE:");
  43. lines.push("The top-level spec has these fields:");
  44. lines.push(
  45. " - metadata: Root-level SEO metadata (title template, description, openGraph, twitter)",
  46. );
  47. lines.push(
  48. " - layouts: Reusable layout element trees. Each layout MUST include a { type: 'Slot' } element where page content will be injected.",
  49. );
  50. lines.push(" - routes: Route definitions keyed by URL pattern");
  51. lines.push(" - state: Global initial state shared across all routes");
  52. lines.push("");
  53. lines.push("ROUTES:");
  54. lines.push("Route keys use Next.js URL patterns:");
  55. lines.push(" - '/' - home page");
  56. lines.push(" - '/about' - static route");
  57. lines.push(" - '/blog/[slug]' - dynamic segment");
  58. lines.push(" - '/docs/[...path]' - catch-all segment");
  59. lines.push(" - '/settings/[[...path]]' - optional catch-all segment");
  60. lines.push("");
  61. lines.push(
  62. "IMPORTANT: In JSON Patch paths, forward slashes in route keys must be escaped as ~1.",
  63. );
  64. lines.push(" - Route '/' becomes path '/routes/~1'");
  65. lines.push(" - Route '/about' becomes path '/routes/~1about'");
  66. lines.push(" - Route '/blog/[slug]' becomes path '/routes/~1blog~1[slug]'");
  67. lines.push("");
  68. lines.push("Each route has:");
  69. lines.push(
  70. " - page: Element tree (root + elements + optional state) — the page content",
  71. );
  72. lines.push(
  73. " - metadata: Per-route SEO metadata (title, description, openGraph)",
  74. );
  75. lines.push(" - layout: Layout key referencing an entry in the layouts map");
  76. lines.push(" - loading: Optional loading state element tree");
  77. lines.push(" - error: Optional error state element tree");
  78. lines.push(" - loader: Optional server-side data loader name");
  79. lines.push("");
  80. lines.push("LAYOUTS:");
  81. lines.push(
  82. "Layouts wrap page content. A layout element tree MUST include a component with type 'Slot' — this is where the page content will be rendered.",
  83. );
  84. lines.push(
  85. "Layouts are defined once and reused across routes via the layout field.",
  86. );
  87. lines.push("");
  88. lines.push("METADATA:");
  89. lines.push("Root metadata sets defaults. Route metadata overrides per page.");
  90. lines.push(
  91. " - title: string or { default, template } (use %s for page title in template)",
  92. );
  93. lines.push(" - description: string");
  94. lines.push(" - openGraph: { title, description, images, type }");
  95. lines.push(" - twitter: { card, title, description }");
  96. lines.push("");
  97. lines.push("PAGE CONTENT:");
  98. lines.push(
  99. "Each page uses the standard json-render element tree format: root, elements, optional state.",
  100. );
  101. lines.push(
  102. "Elements have type, props, children, and optionally visible, on, repeat, watch fields.",
  103. );
  104. lines.push("");
  105. const catalogData = catalog as {
  106. components?: Record<
  107. string,
  108. {
  109. description?: string;
  110. props?: unknown;
  111. slots?: string[];
  112. events?: string[];
  113. }
  114. >;
  115. actions?: Record<string, { description?: string }>;
  116. };
  117. if (catalogData.components) {
  118. lines.push(
  119. `AVAILABLE COMPONENTS (${Object.keys(catalogData.components).length}):`,
  120. );
  121. lines.push("");
  122. for (const [name, def] of Object.entries(catalogData.components)) {
  123. const propsStr = def.props ? formatZodType(def.props as any) : "{}";
  124. const hasChildren = def.slots && def.slots.length > 0;
  125. const childrenStr = hasChildren ? " [accepts children]" : "";
  126. const eventsStr =
  127. def.events && def.events.length > 0
  128. ? ` [events: ${def.events.join(", ")}]`
  129. : "";
  130. const descStr = def.description ? ` - ${def.description}` : "";
  131. lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`);
  132. }
  133. lines.push("");
  134. lines.push("Built-in components (always available):");
  135. lines.push(
  136. "- Slot: {} - Placeholder in layouts where page content is rendered. Required in every layout.",
  137. );
  138. lines.push(
  139. "- Link: { href: string } [accepts children] - Client-side navigation link (renders as next/link).",
  140. );
  141. lines.push("");
  142. }
  143. if (catalogData.actions && Object.keys(catalogData.actions).length > 0) {
  144. lines.push("AVAILABLE ACTIONS:");
  145. lines.push("");
  146. for (const [name, def] of Object.entries(catalogData.actions)) {
  147. lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
  148. }
  149. lines.push("");
  150. }
  151. lines.push("BUILT-IN ACTIONS:");
  152. lines.push(
  153. "- setState: Update a value in the state model. Params: { statePath: string, value: any }",
  154. );
  155. lines.push(
  156. "- pushState: Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }",
  157. );
  158. lines.push(
  159. "- removeState: Remove an item from an array by index. Params: { statePath: string, index: number }",
  160. );
  161. lines.push("- navigate: Navigate to a route. Params: { href: string }");
  162. lines.push("");
  163. lines.push("RULES:");
  164. const baseRules = [
  165. "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
  166. "First add /metadata with the app-level metadata including title template",
  167. "Then add /layouts with reusable layout definitions (each must have a Slot component)",
  168. "Then add /routes with each route's page, metadata, and layout reference",
  169. "Every layout MUST include a { type: 'Slot' } element where page content is injected",
  170. "ONLY use components listed in AVAILABLE COMPONENTS (plus built-in Slot and Link)",
  171. "Each element needs: type, props, children (array of child keys)",
  172. "Use unique keys for element map entries",
  173. "Escape forward slashes in route keys as ~1 in JSON Patch paths",
  174. "Include realistic sample data in state for data-driven pages",
  175. "Use Link component for navigation between routes",
  176. "Create a cohesive multi-page app with consistent layouts and navigation",
  177. ];
  178. const allRules = [...baseRules, ...customRules];
  179. allRules.forEach((rule, i) => {
  180. lines.push(`${i + 1}. ${rule}`);
  181. });
  182. return lines.join("\n");
  183. }
  184. /**
  185. * The schema for @json-render/next.
  186. *
  187. * Defines a multi-page Next.js application structure:
  188. * - Spec: Routes with pages, layouts, metadata, loading/error states
  189. * - Catalog: Components with props schemas, and optional actions
  190. *
  191. * This schema is fundamentally different from the React element tree schema.
  192. * It's page-based, designed for full Next.js applications with SSR,
  193. * routing, and metadata generation.
  194. */
  195. export const schema = defineSchema(
  196. (s) => ({
  197. spec: s.object({
  198. /** Root-level metadata applied as defaults */
  199. metadata: s.object({
  200. title: s.any(),
  201. description: s.string(),
  202. keywords: s.array(s.string()),
  203. openGraph: s.any(),
  204. twitter: s.any(),
  205. robots: s.any(),
  206. icons: s.any(),
  207. }),
  208. /** Route definitions keyed by URL pattern */
  209. routes: s.record(
  210. s.object({
  211. /** Page element tree */
  212. page: s.object({
  213. root: s.string(),
  214. elements: s.record(
  215. s.object({
  216. type: s.ref("catalog.components"),
  217. props: s.propsOf("catalog.components"),
  218. children: s.array(s.string()),
  219. visible: s.any(),
  220. }),
  221. ),
  222. state: s.any(),
  223. }),
  224. /** Per-route metadata */
  225. metadata: s.any(),
  226. /** Layout key */
  227. layout: s.string(),
  228. /** Loading state element tree */
  229. loading: s.any(),
  230. /** Error state element tree */
  231. error: s.any(),
  232. /** Not-found element tree */
  233. notFound: s.any(),
  234. /** Server-side data loader name */
  235. loader: s.string(),
  236. /** Static params for SSG */
  237. staticParams: s.any(),
  238. }),
  239. ),
  240. /** Reusable layout element trees */
  241. layouts: s.record(
  242. s.object({
  243. root: s.string(),
  244. elements: s.record(
  245. s.object({
  246. type: s.ref("catalog.components"),
  247. props: s.propsOf("catalog.components"),
  248. children: s.array(s.string()),
  249. visible: s.any(),
  250. }),
  251. ),
  252. state: s.any(),
  253. }),
  254. ),
  255. /** Global initial state */
  256. state: s.any(),
  257. }),
  258. catalog: s.object({
  259. /** Component definitions */
  260. components: s.map({
  261. props: s.zod(),
  262. slots: s.array(s.string()),
  263. description: s.string(),
  264. example: s.any(),
  265. }),
  266. /** Action definitions (optional) */
  267. actions: s.map({
  268. params: s.zod(),
  269. description: s.string(),
  270. }),
  271. }),
  272. }),
  273. {
  274. promptTemplate: nextAppPromptTemplate,
  275. builtInActions: [
  276. {
  277. name: "setState",
  278. description:
  279. "Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
  280. },
  281. {
  282. name: "pushState",
  283. description:
  284. 'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
  285. },
  286. {
  287. name: "removeState",
  288. description:
  289. "Remove an item from an array in state by index. Params: { statePath: string, index: number }",
  290. },
  291. {
  292. name: "navigate",
  293. description:
  294. "Navigate to a route within the app. Params: { href: string }",
  295. },
  296. ],
  297. },
  298. );
  299. /**
  300. * Type for the Next.js schema
  301. */
  302. export type NextSchema = typeof schema;
  303. /**
  304. * Infer the spec type from a catalog
  305. */
  306. export type NextSpec<TCatalog> = typeof schema extends {
  307. createCatalog: (catalog: TCatalog) => { _specType: infer S };
  308. }
  309. ? S
  310. : never;