Repository: vercel-labs/json-render Branch: main Commit: b7993ed4ae81 Files: 1089 Total size: 4.1 MB Directory structure: gitextract_2q_khw51/ ├── .changeset/ │ ├── README.md │ └── config.json ├── .cursor/ │ └── mcp.json ├── .github/ │ └── workflows/ │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .npmrc ├── .vscode/ │ └── mcp.json ├── AGENTS.md ├── LICENSE ├── README.md ├── apps/ │ └── web/ │ ├── .env.example │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── app/ │ │ ├── (main)/ │ │ │ ├── docs/ │ │ │ │ ├── a2ui/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── adaptive-cards/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── ag-ui/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── ai-sdk/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── api/ │ │ │ │ │ ├── codegen/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── core/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── image/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── jotai/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── mcp/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── react/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── react-email/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── react-native/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── react-pdf/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── react-three-fiber/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── redux/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── remotion/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── shadcn/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── solid/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── svelte/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── vue/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── xstate/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ ├── yaml/ │ │ │ │ │ │ └── page.mdx │ │ │ │ │ └── zustand/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── catalog/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── changelog/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── code-export/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── computed-values/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── custom-schema/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── data-binding/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── generation-modes/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── installation/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── layout.tsx │ │ │ │ ├── migration/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── openapi/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── page.mdx │ │ │ │ ├── quick-start/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── registry/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── renderers/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── schemas/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── skills/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── specs/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── streaming/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── validation/ │ │ │ │ │ └── page.mdx │ │ │ │ ├── visibility/ │ │ │ │ │ └── page.mdx │ │ │ │ └── watchers/ │ │ │ │ └── page.mdx │ │ │ ├── examples/ │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── api/ │ │ │ ├── docs-chat/ │ │ │ │ └── route.ts │ │ │ ├── docs-markdown/ │ │ │ │ └── route.ts │ │ │ ├── generate/ │ │ │ │ └── route.ts │ │ │ └── search/ │ │ │ └── route.ts │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ ├── og/ │ │ │ ├── [...slug]/ │ │ │ │ └── route.tsx │ │ │ ├── og-image.tsx │ │ │ └── route.tsx │ │ └── playground/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components/ │ │ ├── code-block.tsx │ │ ├── code-tabs.tsx │ │ ├── code.tsx │ │ ├── copy-button.tsx │ │ ├── copy-page-button.tsx │ │ ├── demo.tsx │ │ ├── docs-chat.tsx │ │ ├── docs-mobile-nav.tsx │ │ ├── docs-sidebar.tsx │ │ ├── expandable-code.tsx │ │ ├── generation-modes-diagram.tsx │ │ ├── header.tsx │ │ ├── package-install.tsx │ │ ├── playground.tsx │ │ ├── search.tsx │ │ ├── table-of-contents.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ └── ui/ │ │ ├── accordion.tsx │ │ ├── alert.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx │ ├── components.json │ ├── eslint.config.js │ ├── lib/ │ │ ├── docs-navigation.ts │ │ ├── examples.ts │ │ ├── mdx-to-markdown.ts │ │ ├── page-metadata.ts │ │ ├── page-titles.ts │ │ ├── rate-limit.ts │ │ ├── render/ │ │ │ ├── catalog-display.ts │ │ │ ├── catalog.ts │ │ │ ├── registry.tsx │ │ │ └── renderer.tsx │ │ ├── search-index.ts │ │ ├── spec-patch.ts │ │ ├── use-playground-stream.ts │ │ └── utils.ts │ ├── mdx-components.tsx │ ├── next.config.js │ ├── package.json │ ├── postcss.config.mjs │ └── tsconfig.json ├── examples/ │ ├── chat/ │ │ ├── .env.example │ │ ├── CHANGELOG.md │ │ ├── app/ │ │ │ ├── api/ │ │ │ │ └── generate/ │ │ │ │ └── route.ts │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── components/ │ │ │ ├── theme-provider.tsx │ │ │ ├── theme-toggle.tsx │ │ │ └── ui/ │ │ │ ├── accordion.tsx │ │ │ ├── alert.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── chart.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── table.tsx │ │ │ └── tabs.tsx │ │ ├── eslint.config.js │ │ ├── lib/ │ │ │ ├── agent.ts │ │ │ ├── rate-limit.ts │ │ │ ├── render/ │ │ │ │ ├── catalog.ts │ │ │ │ ├── registry.tsx │ │ │ │ └── renderer.tsx │ │ │ ├── tools/ │ │ │ │ ├── crypto.ts │ │ │ │ ├── github.ts │ │ │ │ ├── hackernews.ts │ │ │ │ ├── search.ts │ │ │ │ └── weather.ts │ │ │ └── utils.ts │ │ ├── next-env.d.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ └── tsconfig.json │ ├── dashboard/ │ │ ├── .env.example │ │ ├── CHANGELOG.md │ │ ├── app/ │ │ │ ├── api/ │ │ │ │ ├── generate/ │ │ │ │ │ └── route.ts │ │ │ │ └── v1/ │ │ │ │ ├── accounts/ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── customers/ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── expenses/ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ ├── approve/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── reject/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── invoices/ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ ├── mark-paid/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── send/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── reports/ │ │ │ │ │ ├── export/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── profit-loss/ │ │ │ │ │ └── route.ts │ │ │ │ ├── reset/ │ │ │ │ │ └── route.ts │ │ │ │ └── widgets/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── reorder/ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── components/ │ │ │ ├── add-widget-card.tsx │ │ │ ├── code-highlight.tsx │ │ │ ├── header.tsx │ │ │ ├── sortable-widget.tsx │ │ │ ├── theme-provider.tsx │ │ │ ├── theme-toggle.tsx │ │ │ ├── ui/ │ │ │ │ ├── accordion.tsx │ │ │ │ ├── alert.tsx │ │ │ │ ├── animated-border.tsx │ │ │ │ ├── avatar.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── chart.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── drawer.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── pagination.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── progress.tsx │ │ │ │ ├── radio-group.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── textarea.tsx │ │ │ │ └── tooltip.tsx │ │ │ └── widget.tsx │ │ ├── components.json │ │ ├── drizzle.config.ts │ │ ├── eslint.config.js │ │ ├── lib/ │ │ │ ├── db/ │ │ │ │ ├── connection.ts │ │ │ │ ├── schema.ts │ │ │ │ └── store.ts │ │ │ ├── rate-limit.ts │ │ │ ├── render/ │ │ │ │ ├── catalog.ts │ │ │ │ ├── registry.tsx │ │ │ │ └── renderer.tsx │ │ │ └── utils.ts │ │ ├── next-env.d.ts │ │ ├── next.config.js │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── scripts/ │ │ │ ├── clear.ts │ │ │ └── seed.ts │ │ └── tsconfig.json │ ├── image/ │ │ ├── .env.example │ │ ├── CHANGELOG.md │ │ ├── app/ │ │ │ ├── api/ │ │ │ │ ├── generate/ │ │ │ │ │ └── route.ts │ │ │ │ └── image/ │ │ │ │ └── route.ts │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── components/ │ │ │ └── ui/ │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ └── sheet.tsx │ │ ├── components.json │ │ ├── eslint.config.js │ │ ├── lib/ │ │ │ ├── catalog.ts │ │ │ ├── examples.ts │ │ │ ├── rate-limit.ts │ │ │ └── utils.ts │ │ ├── next-env.d.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ └── tsconfig.json │ ├── mcp/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── index.html │ │ ├── package.json │ │ ├── server.ts │ │ ├── src/ │ │ │ ├── catalog.ts │ │ │ ├── globals.css │ │ │ ├── main.tsx │ │ │ └── mcp-app-view.tsx │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── no-ai/ │ │ ├── CHANGELOG.md │ │ ├── app/ │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── eslint.config.js │ │ ├── lib/ │ │ │ ├── examples.ts │ │ │ └── render/ │ │ │ ├── catalog.ts │ │ │ └── registry.tsx │ │ ├── next-env.d.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ └── tsconfig.json │ ├── react-email/ │ │ ├── .env.example │ │ ├── CHANGELOG.md │ │ ├── app/ │ │ │ ├── api/ │ │ │ │ ├── email/ │ │ │ │ │ └── route.ts │ │ │ │ └── generate/ │ │ │ │ └── route.ts │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── components/ │ │ │ └── ui/ │ │ │ ├── button.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── tabs.tsx │ │ │ └── textarea.tsx │ │ ├── components.json │ │ ├── lib/ │ │ │ ├── catalog.ts │ │ │ ├── examples.ts │ │ │ ├── rate-limit.ts │ │ │ └── utils.ts │ │ ├── next-env.d.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ └── tsconfig.json │ ├── react-native/ │ │ ├── .env.example │ │ ├── CHANGELOG.md │ │ ├── app/ │ │ │ ├── _layout.tsx │ │ │ ├── api/ │ │ │ │ └── generate+api.ts │ │ │ └── index.tsx │ │ ├── app.json │ │ ├── lib/ │ │ │ └── render/ │ │ │ ├── catalog.ts │ │ │ ├── registry.tsx │ │ │ └── renderer.tsx │ │ ├── metro.config.js │ │ ├── package.json │ │ └── tsconfig.json │ ├── react-pdf/ │ │ ├── .env.example │ │ ├── CHANGELOG.md │ │ ├── app/ │ │ │ ├── api/ │ │ │ │ ├── generate/ │ │ │ │ │ └── route.ts │ │ │ │ └── pdf/ │ │ │ │ └── route.ts │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── components/ │ │ │ └── ui/ │ │ │ ├── button.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── tabs.tsx │ │ │ └── textarea.tsx │ │ ├── components.json │ │ ├── lib/ │ │ │ ├── catalog.ts │ │ │ ├── examples.ts │ │ │ ├── rate-limit.ts │ │ │ └── utils.ts │ │ ├── next-env.d.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ └── tsconfig.json │ ├── react-three-fiber/ │ │ ├── CHANGELOG.md │ │ ├── app/ │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── scenes/ │ │ │ ├── _helpers.ts │ │ │ ├── clockwork-orrery.ts │ │ │ ├── deep-sea-abyss.ts │ │ │ ├── floating-islands.ts │ │ │ ├── hyperspace-tunnel.ts │ │ │ ├── index.ts │ │ │ ├── mystify.ts │ │ │ ├── orbital-chaos.ts │ │ │ ├── perpetual-motion.ts │ │ │ ├── pipes.ts │ │ │ ├── portal-gallery.ts │ │ │ ├── product-showroom.ts │ │ │ ├── starfield.ts │ │ │ └── storm-cell.ts │ │ ├── eslint.config.js │ │ ├── next-env.d.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── remotion/ │ │ ├── .env.example │ │ ├── CHANGELOG.md │ │ ├── app/ │ │ │ ├── api/ │ │ │ │ └── generate/ │ │ │ │ └── route.ts │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── lib/ │ │ │ ├── catalog.ts │ │ │ ├── rate-limit.ts │ │ │ └── utils.ts │ │ ├── next-env.d.ts │ │ ├── next.config.js │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ └── tsconfig.json │ ├── solid/ │ │ ├── CHANGELOG.md │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ ├── DemoRenderer.tsx │ │ │ ├── app.css │ │ │ ├── index.tsx │ │ │ └── lib/ │ │ │ ├── catalog.ts │ │ │ ├── components/ │ │ │ │ ├── Badge.tsx │ │ │ │ ├── Button.tsx │ │ │ │ ├── Card.tsx │ │ │ │ ├── Input.tsx │ │ │ │ ├── ListItem.tsx │ │ │ │ ├── Stack.tsx │ │ │ │ └── Text.tsx │ │ │ ├── registry.tsx │ │ │ └── spec.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── stripe-app/ │ │ ├── README.md │ │ ├── api/ │ │ │ ├── .env.example │ │ │ ├── README.md │ │ │ ├── app/ │ │ │ │ ├── api/ │ │ │ │ │ └── generate/ │ │ │ │ │ └── route.ts │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── next-env.d.ts │ │ │ ├── next.config.ts │ │ │ ├── package.json │ │ │ └── tsconfig.json │ │ ├── drawer-app/ │ │ │ ├── .env.example │ │ │ ├── CHANGELOG.md │ │ │ ├── README.md │ │ │ ├── eslint.config.js │ │ │ ├── jest.config.js │ │ │ ├── package.json │ │ │ ├── scripts/ │ │ │ │ └── setup.mjs │ │ │ ├── src/ │ │ │ │ ├── lib/ │ │ │ │ │ ├── config.ts │ │ │ │ │ ├── render/ │ │ │ │ │ │ ├── catalog/ │ │ │ │ │ │ │ ├── actions.ts │ │ │ │ │ │ │ └── components.tsx │ │ │ │ │ │ ├── catalog.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── renderer.tsx │ │ │ │ │ ├── stream-spec.ts │ │ │ │ │ └── stripe.ts │ │ │ │ └── views/ │ │ │ │ ├── CustomerDetails.test.tsx │ │ │ │ ├── CustomerDetails.tsx │ │ │ │ ├── Customers.test.tsx │ │ │ │ ├── Customers.tsx │ │ │ │ ├── Home.test.tsx │ │ │ │ ├── Home.tsx │ │ │ │ ├── Invoices.tsx │ │ │ │ ├── PaymentDetails.tsx │ │ │ │ ├── Payments.tsx │ │ │ │ ├── Products.tsx │ │ │ │ └── Subscriptions.tsx │ │ │ ├── stripe-app.template.json │ │ │ ├── tsconfig.json │ │ │ └── ui-extensions.d.ts │ │ └── fullpage-app/ │ │ ├── .env.example │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── scripts/ │ │ │ └── setup.mjs │ │ ├── src/ │ │ │ ├── lib/ │ │ │ │ ├── config.ts │ │ │ │ ├── render/ │ │ │ │ │ ├── catalog/ │ │ │ │ │ │ ├── actions.ts │ │ │ │ │ │ └── components.tsx │ │ │ │ │ ├── catalog.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── renderer.tsx │ │ │ │ ├── stream-spec.ts │ │ │ │ └── stripe.ts │ │ │ └── views/ │ │ │ └── FullPage.tsx │ │ ├── stripe-app.template.json │ │ ├── tsconfig.json │ │ └── ui-extensions.d.ts │ ├── svelte/ │ │ ├── CHANGELOG.md │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.svelte │ │ │ ├── DemoRenderer.svelte │ │ │ ├── app.css │ │ │ ├── lib/ │ │ │ │ ├── catalog.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── Badge.svelte │ │ │ │ │ ├── Button.svelte │ │ │ │ │ ├── Card.svelte │ │ │ │ │ ├── Input.svelte │ │ │ │ │ ├── ListItem.svelte │ │ │ │ │ ├── Stack.svelte │ │ │ │ │ └── Text.svelte │ │ │ │ ├── registry.ts │ │ │ │ └── spec.ts │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── svelte.config.js │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── svelte-chat/ │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── components.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app.css │ │ │ ├── app.d.ts │ │ │ ├── app.html │ │ │ ├── lib/ │ │ │ │ ├── agent.ts │ │ │ │ ├── components/ │ │ │ │ │ └── ui/ │ │ │ │ │ ├── accordion/ │ │ │ │ │ │ ├── accordion-content.svelte │ │ │ │ │ │ ├── accordion-item.svelte │ │ │ │ │ │ ├── accordion-trigger.svelte │ │ │ │ │ │ ├── accordion.svelte │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── alert/ │ │ │ │ │ │ ├── alert-description.svelte │ │ │ │ │ │ ├── alert-title.svelte │ │ │ │ │ │ ├── alert.svelte │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── badge/ │ │ │ │ │ │ ├── badge.svelte │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── button/ │ │ │ │ │ │ ├── button.svelte │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── card/ │ │ │ │ │ │ ├── card-action.svelte │ │ │ │ │ │ ├── card-content.svelte │ │ │ │ │ │ ├── card-description.svelte │ │ │ │ │ │ ├── card-footer.svelte │ │ │ │ │ │ ├── card-header.svelte │ │ │ │ │ │ ├── card-title.svelte │ │ │ │ │ │ ├── card.svelte │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── input/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── input.svelte │ │ │ │ │ ├── label/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── label.svelte │ │ │ │ │ ├── progress/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── progress.svelte │ │ │ │ │ ├── radio-group/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── radio-group-item.svelte │ │ │ │ │ │ └── radio-group.svelte │ │ │ │ │ ├── select/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── select-content.svelte │ │ │ │ │ │ ├── select-group-heading.svelte │ │ │ │ │ │ ├── select-group.svelte │ │ │ │ │ │ ├── select-item.svelte │ │ │ │ │ │ ├── select-label.svelte │ │ │ │ │ │ ├── select-portal.svelte │ │ │ │ │ │ ├── select-scroll-down-button.svelte │ │ │ │ │ │ ├── select-scroll-up-button.svelte │ │ │ │ │ │ ├── select-separator.svelte │ │ │ │ │ │ ├── select-trigger.svelte │ │ │ │ │ │ └── select.svelte │ │ │ │ │ ├── separator/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── separator.svelte │ │ │ │ │ ├── table/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── table-body.svelte │ │ │ │ │ │ ├── table-caption.svelte │ │ │ │ │ │ ├── table-cell.svelte │ │ │ │ │ │ ├── table-footer.svelte │ │ │ │ │ │ ├── table-head.svelte │ │ │ │ │ │ ├── table-header.svelte │ │ │ │ │ │ ├── table-row.svelte │ │ │ │ │ │ └── table.svelte │ │ │ │ │ └── tabs/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── tabs-content.svelte │ │ │ │ │ ├── tabs-list.svelte │ │ │ │ │ ├── tabs-trigger.svelte │ │ │ │ │ └── tabs.svelte │ │ │ │ ├── index.ts │ │ │ │ ├── rate-limit.ts │ │ │ │ ├── render/ │ │ │ │ │ ├── Renderer.svelte │ │ │ │ │ ├── catalog.ts │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── Accordion.svelte │ │ │ │ │ │ ├── Alert.svelte │ │ │ │ │ │ ├── Badge.svelte │ │ │ │ │ │ ├── BarChart.svelte │ │ │ │ │ │ ├── Button.svelte │ │ │ │ │ │ ├── Callout.svelte │ │ │ │ │ │ ├── Card.svelte │ │ │ │ │ │ ├── Grid.svelte │ │ │ │ │ │ ├── Heading.svelte │ │ │ │ │ │ ├── LineChart.svelte │ │ │ │ │ │ ├── Link.svelte │ │ │ │ │ │ ├── Metric.svelte │ │ │ │ │ │ ├── PieChart.svelte │ │ │ │ │ │ ├── Progress.svelte │ │ │ │ │ │ ├── RadioGroup.svelte │ │ │ │ │ │ ├── SelectInput.svelte │ │ │ │ │ │ ├── Separator.svelte │ │ │ │ │ │ ├── Skeleton.svelte │ │ │ │ │ │ ├── Stack.svelte │ │ │ │ │ │ ├── TabContent.svelte │ │ │ │ │ │ ├── Table.svelte │ │ │ │ │ │ ├── Tabs.svelte │ │ │ │ │ │ ├── Text.svelte │ │ │ │ │ │ ├── TextInput.svelte │ │ │ │ │ │ └── Timeline.svelte │ │ │ │ │ └── registry.ts │ │ │ │ ├── tools/ │ │ │ │ │ ├── crypto.ts │ │ │ │ │ ├── github.ts │ │ │ │ │ ├── hackernews.ts │ │ │ │ │ ├── search.ts │ │ │ │ │ └── weather.ts │ │ │ │ └── utils.ts │ │ │ └── routes/ │ │ │ ├── +layout.svelte │ │ │ ├── +page.svelte │ │ │ └── api/ │ │ │ └── generate/ │ │ │ └── +server.ts │ │ ├── static/ │ │ │ └── robots.txt │ │ ├── svelte.config.js │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── vite-renderers/ │ │ ├── CHANGELOG.md │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── main.ts │ │ │ ├── react/ │ │ │ │ ├── App.tsx │ │ │ │ ├── catalog.ts │ │ │ │ ├── mount.tsx │ │ │ │ └── registry.tsx │ │ │ ├── shared/ │ │ │ │ ├── catalog-def.ts │ │ │ │ ├── handlers.ts │ │ │ │ └── styles.css │ │ │ ├── solid/ │ │ │ │ ├── App.tsx │ │ │ │ ├── DemoRenderer.tsx │ │ │ │ ├── catalog.ts │ │ │ │ ├── mount.tsx │ │ │ │ └── registry.tsx │ │ │ ├── spec.ts │ │ │ ├── svelte/ │ │ │ │ ├── App.svelte │ │ │ │ ├── DemoRenderer.svelte │ │ │ │ ├── catalog.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── Badge.svelte │ │ │ │ │ ├── Button.svelte │ │ │ │ │ ├── Card.svelte │ │ │ │ │ ├── Input.svelte │ │ │ │ │ ├── ListItem.svelte │ │ │ │ │ ├── RendererBadge.svelte │ │ │ │ │ ├── RendererTabs.svelte │ │ │ │ │ ├── Stack.svelte │ │ │ │ │ └── Text.svelte │ │ │ │ ├── mount.ts │ │ │ │ └── registry.ts │ │ │ └── vue/ │ │ │ ├── App.vue │ │ │ ├── DemoRenderer.vue │ │ │ ├── catalog.ts │ │ │ ├── mount.ts │ │ │ └── registry.ts │ │ ├── svelte.config.js │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── vue/ │ ├── CHANGELOG.md │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.vue │ │ ├── DemoRenderer.vue │ │ ├── lib/ │ │ │ ├── catalog.ts │ │ │ ├── registry.ts │ │ │ └── spec.ts │ │ └── main.ts │ ├── tsconfig.json │ └── vite.config.ts ├── package.json ├── packages/ │ ├── codegen/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── serialize.ts │ │ │ ├── traverse.test.ts │ │ │ ├── traverse.ts │ │ │ └── types.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── core/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── actions.test.ts │ │ │ ├── actions.ts │ │ │ ├── diff.ts │ │ │ ├── edit-modes.ts │ │ │ ├── env.d.ts │ │ │ ├── index.ts │ │ │ ├── merge.ts │ │ │ ├── prompt.ts │ │ │ ├── props.test.ts │ │ │ ├── props.ts │ │ │ ├── schema.test.ts │ │ │ ├── schema.ts │ │ │ ├── spec-validator.test.ts │ │ │ ├── spec-validator.ts │ │ │ ├── state-store.test.ts │ │ │ ├── state-store.ts │ │ │ ├── store-utils.ts │ │ │ ├── types.test.ts │ │ │ ├── types.ts │ │ │ ├── validation.test.ts │ │ │ ├── validation.ts │ │ │ ├── visibility.test.ts │ │ │ └── visibility.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── eslint-config/ │ │ ├── README.md │ │ ├── base.js │ │ ├── next.js │ │ ├── package.json │ │ └── react-internal.js │ ├── image/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── catalog-types.ts │ │ │ ├── catalog.ts │ │ │ ├── components/ │ │ │ │ ├── index.ts │ │ │ │ └── standard.tsx │ │ │ ├── index.ts │ │ │ ├── render.test.tsx │ │ │ ├── render.tsx │ │ │ ├── schema.ts │ │ │ ├── server.ts │ │ │ └── types.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── jotai/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── mcp/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app.ts │ │ │ ├── build-app-html-entry.ts │ │ │ ├── build-app-html.ts │ │ │ ├── index.ts │ │ │ ├── server.ts │ │ │ ├── types.ts │ │ │ └── use-json-render-app.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── react/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── catalog-types.ts │ │ │ ├── chained-actions.test.tsx │ │ │ ├── contexts/ │ │ │ │ ├── actions.tsx │ │ │ │ ├── repeat-scope.tsx │ │ │ │ ├── state.test.tsx │ │ │ │ ├── state.tsx │ │ │ │ ├── validation.tsx │ │ │ │ ├── visibility.test.tsx │ │ │ │ └── visibility.tsx │ │ │ ├── dynamic-forms.test.tsx │ │ │ ├── hooks.test.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── renderer.test.tsx │ │ │ ├── renderer.tsx │ │ │ └── schema.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── react-email/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __fixtures__/ │ │ │ │ └── examples.ts │ │ │ ├── catalog-types.ts │ │ │ ├── catalog.ts │ │ │ ├── components/ │ │ │ │ ├── index.ts │ │ │ │ └── standard.tsx │ │ │ ├── contexts/ │ │ │ │ ├── actions.tsx │ │ │ │ ├── repeat-scope.tsx │ │ │ │ ├── state.tsx │ │ │ │ ├── validation.tsx │ │ │ │ └── visibility.tsx │ │ │ ├── index.ts │ │ │ ├── render.test.tsx │ │ │ ├── render.tsx │ │ │ ├── renderer.tsx │ │ │ ├── schema.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── react-native/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── catalog-types.ts │ │ │ ├── catalog.ts │ │ │ ├── components/ │ │ │ │ └── standard.tsx │ │ │ ├── contexts/ │ │ │ │ ├── actions.tsx │ │ │ │ ├── repeat-scope.tsx │ │ │ │ ├── state.tsx │ │ │ │ ├── validation.tsx │ │ │ │ └── visibility.tsx │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── renderer.tsx │ │ │ └── schema.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── react-pdf/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── catalog-types.ts │ │ │ ├── catalog.ts │ │ │ ├── components/ │ │ │ │ ├── index.ts │ │ │ │ └── standard.tsx │ │ │ ├── contexts/ │ │ │ │ ├── actions.tsx │ │ │ │ ├── repeat-scope.tsx │ │ │ │ ├── state.test.tsx │ │ │ │ ├── state.tsx │ │ │ │ ├── validation.tsx │ │ │ │ └── visibility.tsx │ │ │ ├── index.ts │ │ │ ├── render.tsx │ │ │ ├── renderer.tsx │ │ │ ├── schema.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── react-state/ │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── env.d.ts │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── tsconfig.json │ │ ├── tsup.config.ts │ │ └── vitest.config.ts │ ├── react-three-fiber/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── catalog.ts │ │ │ ├── components.tsx │ │ │ ├── index.ts │ │ │ ├── r3f-jsx.d.ts │ │ │ ├── renderer.tsx │ │ │ └── schemas.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── redux/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── remotion/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── catalog-types.ts │ │ │ ├── catalog.ts │ │ │ ├── components/ │ │ │ │ ├── ClipWrapper.tsx │ │ │ │ ├── Renderer.tsx │ │ │ │ ├── hooks.ts │ │ │ │ ├── index.ts │ │ │ │ ├── standard.tsx │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── shadcn/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── components.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── catalog.ts │ │ │ ├── components.tsx │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ └── utils.ts │ │ │ └── ui/ │ │ │ ├── accordion.tsx │ │ │ ├── alert.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── pagination.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── solid/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── catalog-types.ts │ │ │ ├── chained-actions.test.tsx │ │ │ ├── contexts/ │ │ │ │ ├── actions.test.tsx │ │ │ │ ├── actions.tsx │ │ │ │ ├── repeat-scope.tsx │ │ │ │ ├── state.test.tsx │ │ │ │ ├── state.tsx │ │ │ │ ├── validation.test.tsx │ │ │ │ ├── validation.tsx │ │ │ │ ├── visibility.test.tsx │ │ │ │ └── visibility.tsx │ │ │ ├── dynamic-forms.test.tsx │ │ │ ├── hooks.test.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── renderer.test.tsx │ │ │ ├── renderer.tsx │ │ │ └── schema.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── svelte/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── CatalogRenderer.svelte │ │ │ ├── ConfirmDialog.svelte │ │ │ ├── ConfirmDialogManager.svelte │ │ │ ├── ElementRenderer.svelte │ │ │ ├── JsonUIProvider.svelte │ │ │ ├── Renderer.svelte │ │ │ ├── RendererWithProvider.test.svelte │ │ │ ├── RepeatChildren.svelte │ │ │ ├── TestButton.svelte │ │ │ ├── TestContainer.svelte │ │ │ ├── TestText.svelte │ │ │ ├── catalog-types.ts │ │ │ ├── contexts/ │ │ │ │ ├── ActionProvider.svelte │ │ │ │ ├── FunctionsContextProvider.svelte │ │ │ │ ├── RepeatScopeProvider.svelte │ │ │ │ ├── StateProvider.svelte │ │ │ │ ├── ValidationProvider.svelte │ │ │ │ ├── VisibilityProvider.svelte │ │ │ │ ├── actions.test.ts │ │ │ │ ├── state.test.ts │ │ │ │ └── visibility.test.ts │ │ │ ├── index.ts │ │ │ ├── renderer.test.ts │ │ │ ├── renderer.ts │ │ │ ├── schema.ts │ │ │ ├── streaming.svelte.ts │ │ │ ├── utils.svelte.ts │ │ │ └── utils.test.ts │ │ ├── svelte.config.js │ │ └── tsconfig.json │ ├── typescript-config/ │ │ ├── base.json │ │ ├── nextjs.json │ │ ├── package.json │ │ └── react-library.json │ ├── ui/ │ │ ├── eslint.config.mjs │ │ ├── package.json │ │ ├── src/ │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ └── code.tsx │ │ └── tsconfig.json │ ├── vue/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── catalog-types.ts │ │ │ ├── composables/ │ │ │ │ ├── actions.test.ts │ │ │ │ ├── actions.ts │ │ │ │ ├── repeat-scope.ts │ │ │ │ ├── state.test.ts │ │ │ │ ├── state.ts │ │ │ │ ├── validation.test.ts │ │ │ │ ├── validation.ts │ │ │ │ ├── visibility.test.ts │ │ │ │ └── visibility.ts │ │ │ ├── dynamic-forms.test.ts │ │ │ ├── hooks.test.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── renderer.test.ts │ │ │ ├── renderer.ts │ │ │ └── schema.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── xstate/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── yaml/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── diff.test.ts │ │ │ ├── diff.ts │ │ │ ├── index.ts │ │ │ ├── merge.test.ts │ │ │ ├── merge.ts │ │ │ ├── parser.test.ts │ │ │ ├── parser.ts │ │ │ ├── prompt.test.ts │ │ │ ├── prompt.ts │ │ │ ├── transform.test.ts │ │ │ └── transform.ts │ │ ├── tsconfig.json │ │ ├── tsup.config.ts │ │ └── vitest.config.ts │ └── zustand/ │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── index.test.ts │ │ └── index.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── pnpm-workspace.yaml ├── scripts/ │ └── generate-og-images.mts ├── skills/ │ ├── codegen/ │ │ └── SKILL.md │ ├── core/ │ │ └── SKILL.md │ ├── image/ │ │ └── SKILL.md │ ├── jotai/ │ │ └── SKILL.md │ ├── mcp/ │ │ └── SKILL.md │ ├── react/ │ │ └── SKILL.md │ ├── react-email/ │ │ └── SKILL.md │ ├── react-native/ │ │ └── SKILL.md │ ├── react-pdf/ │ │ └── SKILL.md │ ├── react-three-fiber/ │ │ └── SKILL.md │ ├── redux/ │ │ └── SKILL.md │ ├── remotion/ │ │ └── SKILL.md │ ├── remotion-best-practices/ │ │ ├── SKILL.md │ │ └── rules/ │ │ ├── 3d.md │ │ ├── animations.md │ │ ├── assets/ │ │ │ ├── charts-bar-chart.tsx │ │ │ ├── text-animations-typewriter.tsx │ │ │ └── text-animations-word-highlight.tsx │ │ ├── assets.md │ │ ├── audio.md │ │ ├── calculate-metadata.md │ │ ├── can-decode.md │ │ ├── charts.md │ │ ├── compositions.md │ │ ├── display-captions.md │ │ ├── extract-frames.md │ │ ├── fonts.md │ │ ├── get-audio-duration.md │ │ ├── get-video-dimensions.md │ │ ├── get-video-duration.md │ │ ├── gifs.md │ │ ├── images.md │ │ ├── import-srt-captions.md │ │ ├── light-leaks.md │ │ ├── lottie.md │ │ ├── maps.md │ │ ├── measuring-dom-nodes.md │ │ ├── measuring-text.md │ │ ├── parameters.md │ │ ├── sequencing.md │ │ ├── subtitles.md │ │ ├── tailwind.md │ │ ├── text-animations.md │ │ ├── timing.md │ │ ├── transcribe-captions.md │ │ ├── transitions.md │ │ ├── transparent-videos.md │ │ ├── trimming.md │ │ └── videos.md │ ├── shadcn/ │ │ └── SKILL.md │ ├── skill-creator/ │ │ ├── LICENSE.txt │ │ ├── SKILL.md │ │ ├── references/ │ │ │ ├── output-patterns.md │ │ │ └── workflows.md │ │ └── scripts/ │ │ ├── init_skill.py │ │ ├── package_skill.py │ │ └── quick_validate.py │ ├── solid/ │ │ └── SKILL.md │ ├── svelte/ │ │ └── SKILL.md │ ├── vue/ │ │ └── SKILL.md │ ├── xstate/ │ │ └── SKILL.md │ ├── yaml/ │ │ └── SKILL.md │ └── zustand/ │ └── SKILL.md ├── tests/ │ └── e2e/ │ ├── package.json │ ├── state-store-e2e.test.ts │ └── vitest.config.ts ├── turbo.json └── vitest.config.mts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .changeset/README.md ================================================ # Changesets Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in the repository](https://github.com/changesets/changesets). ## Adding a changeset To add a changeset, run `pnpm changeset` in the root of the repository. This will prompt you to select which packages have changed and what type of version bump (major, minor, or patch) should be applied. All `@json-render/*` packages are versioned together -- a changeset for any one of them will bump all packages to the same version. ================================================ FILE: .changeset/config.json ================================================ { "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [ [ "@json-render/core", "@json-render/react", "@json-render/react-email", "@json-render/react-pdf", "@json-render/shadcn", "@json-render/react-native", "@json-render/remotion", "@json-render/codegen", "@json-render/zustand", "@json-render/redux", "@json-render/jotai", "@json-render/vue", "@json-render/xstate", "@json-render/image", "@json-render/mcp", "@json-render/svelte", "@json-render/solid", "@json-render/react-three-fiber", "@json-render/yaml" ] ], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "privatePackages": { "version": true, "tag": false } } ================================================ FILE: .cursor/mcp.json ================================================ { "mcpServers": { "json-render": { "command": "npx", "args": ["tsx", "examples/mcp/server.ts", "--stdio"] } } } ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: - main pull_request: branches: - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint test: name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - name: Build packages run: pnpm turbo run build --filter='./packages/*' - run: pnpm test typecheck: name: Type Check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm type-check ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: branches: - main workflow_dispatch: concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: contents: write pull-requests: write jobs: release: name: Release runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm registry-url: "https://registry.npmjs.org" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Create Release Pull Request or Publish uses: changesets/action@v1 with: version: pnpm ci:version publish: pnpm ci:publish title: "chore: version packages" commit: "chore: version packages" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }} ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Dependencies node_modules .pnp .pnp.js .pnpm-store/ # Local env files .env* !.env.example # Testing coverage # Turbo .turbo # Vercel .vercel # Expo .expo/ # Build Outputs .next/ out/ build dist *.tsbuildinfo .svelte-kit/ # Debug npm-debug.log* yarn-debug.log* yarn-error.log* # Misc .DS_Store *.pem # opensrc - source code for packages opensrc/ # Stripe apps (generated from template + build artifacts) examples/stripe-app/*/stripe-app.json examples/stripe-app/*/.build examples/stripe-app/*/yarn.lock ================================================ FILE: .husky/pre-commit ================================================ pnpm lint-staged ================================================ FILE: .npmrc ================================================ ================================================ FILE: .vscode/mcp.json ================================================ { "servers": { "json-render": { "type": "stdio", "command": "npx", "args": ["tsx", "examples/mcp/server.ts", "--stdio"] } } } ================================================ FILE: AGENTS.md ================================================ # AGENTS.md Instructions for AI coding agents working with this codebase. ## Package Management **Always check the latest version before installing a package.** Before adding or updating any dependency, verify the current latest version on npm: ```bash npm view version ``` Or check multiple packages at once: ```bash npm view ai version npm view @ai-sdk/provider-utils version npm view zod version ``` This ensures we don't install outdated versions that may have incompatible types or missing features. ## Code Style - Do not use emojis in code or UI - Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add ` - **Web app docs (`apps/web/`):** Never use Markdown table syntax (`| col | col |`). Always use HTML `` with ``, ``, ``, `
`, ``. Markdown tables do not render correctly in the web app. Inside HTML table cells, curly braces must be escaped as JSX expressions (e.g. `{'{ "$state": "/path" }'}`) because MDX parses `{` as a JSX expression boundary. ## AI SDK / AI Gateway When using the Vercel AI SDK (`ai` package) with AI Gateway, pass the model as a plain string identifier -- do not import a provider constructor: ```ts import { streamText } from "ai"; const result = streamText({ model: "anthropic/claude-haiku-4.5", prompt: "...", }); ``` This requires `AI_GATEWAY_API_KEY` to be set in the environment. See `tests/e2e/` for examples. ## Dev Servers All apps and examples with dev servers use [portless](https://github.com/vercel-labs/portless) to avoid hardcoded ports. Portless assigns random ports and exposes each app via `.localhost` URLs. Naming convention: - Main web app: `json-render` → `json-render.localhost:1355` - Examples: `[name]-demo.json-render` → `[name]-demo.json-render.localhost:1355` When adding a new example that runs a dev server, wrap its `dev` script with `portless `: ```json { "scripts": { "dev": "portless my-example-demo.json-render next dev --turbopack" } } ``` Do **not** add `--port` flags -- portless handles port assignment automatically. Do **not** add portless as a project dependency; it must be installed globally. ## Workflow - Run `pnpm type-check` after each turn to ensure type safety - When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation: - Package `README.md` files in `packages/*/README.md` - Root `README.md` (if packages table, install commands, or examples are affected) - Web app docs in `apps/web/` (if guides, API references, or examples need updating) - Skills in `skills/*/SKILL.md` (if the package has a corresponding skill) - `AGENTS.md` (if workflow or conventions change) ## Releases This monorepo uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing. ### Fixed version group All public `@json-render/*` packages are in a **fixed** group (see `.changeset/config.json`). A changeset that bumps any one of them bumps all of them to the same version. You only need to list the packages that actually changed in the changeset front matter — the fixed group handles the rest. ### Preparing a release When asked to prepare a release (e.g. "prepare v0.12.0"): 1. **Create a changeset file** at `.changeset/v0--release.md` following the existing pattern: - YAML front matter listing changed packages with bump type (`minor` for feature releases, `patch` for bug-fix-only releases) - A one-line summary, then `### New:` / `### Improved:` / `### Fixed:` sections describing each change - Always list `@json-render/core` plus any packages with actual code changes 2. **Do NOT bump versions** in `package.json` files — CI runs `pnpm ci:version` (which calls `changeset version`) to do that automatically 3. **Do NOT manually write `CHANGELOG.md`** entries — `changeset version` generates them from the changeset file 4. **Add new packages to the fixed group** in `.changeset/config.json` if they should be versioned together with the rest 5. **Fill documentation gaps** — every public package should have: - A row in the root `README.md` packages table - A renderer section in the root `README.md` (if it's a renderer) - An API reference page at `apps/web/app/(main)/docs/api//page.mdx` - An entry in `apps/web/lib/page-titles.ts` and `apps/web/lib/docs-navigation.ts` - An entry in the docs-chat system prompt (`apps/web/app/api/docs-chat/route.ts`) - A skill at `skills//SKILL.md` - A `packages//README.md` 6. **Run `pnpm type-check`** after all changes to verify nothing is broken ### CI scripts - `pnpm changeset` — interactively create a new changeset - `pnpm ci:version` — run `changeset version` + lockfile update (CI only) - `pnpm ci:publish` — build all packages and publish to npm (CI only) ## Source Code Reference Source code for dependencies is available in `opensrc/` for deeper understanding of implementation details. See `opensrc/sources.json` for the list of available packages and their versions. Use this source code when you need to understand how a package works internally, not just its types/interface. ### Fetching Additional Source Code To fetch source code for a package or repository you need to understand, run: ```bash npx opensrc # npm package (e.g., npx opensrc zod) npx opensrc pypi: # Python package (e.g., npx opensrc pypi:requests) npx opensrc crates: # Rust crate (e.g., npx opensrc crates:serde) npx opensrc / # GitHub repo (e.g., npx opensrc vercel/ai) ``` ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2025 Vercel Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # json-render **The Generative UI framework.** Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output. ```bash # for React npm install @json-render/core @json-render/react # for React with pre-built shadcn/ui components npm install @json-render/shadcn # or for React Native npm install @json-render/core @json-render/react-native # or for video npm install @json-render/core @json-render/remotion # or for PDF documents npm install @json-render/core @json-render/react-pdf # or for HTML email npm install @json-render/core @json-render/react-email @react-email/components @react-email/render # or for Vue npm install @json-render/core @json-render/vue # or for Svelte npm install @json-render/core @json-render/svelte # or for SolidJS npm install @json-render/core @json-render/solid # or for 3D scenes npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three ``` ## Why json-render? json-render is a **Generative UI** framework: AI generates interfaces from natural language prompts, constrained to components you define. You set the guardrails, AI generates within them: - **Guardrailed** - AI can only use components in your catalog - **Predictable** - JSON output matches your schema, every time - **Fast** - Stream and render progressively as the model responds - **Cross-Platform** - React, Vue, Svelte, Solid (web), React Native (mobile) from the same catalog - **Batteries Included** - 36 pre-built shadcn/ui components ready to use ## Quick Start ### 1. Define Your Catalog ```typescript import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { z } from "zod"; const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string() }), description: "A card container", }, Metric: { props: z.object({ label: z.string(), value: z.string(), format: z.enum(["currency", "percent", "number"]).nullable(), }), description: "Display a metric value", }, Button: { props: z.object({ label: z.string(), action: z.string(), }), description: "Clickable button", }, }, actions: { export_report: { description: "Export dashboard to PDF" }, refresh_data: { description: "Refresh all metrics" }, }, }); ``` ### 2. Define Your Components ```tsx import { defineRegistry, Renderer } from "@json-render/react"; const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => (

{props.title}

{children}
), Metric: ({ props }) => (
{props.label} {format(props.value, props.format)}
), Button: ({ props, emit }) => ( ), }, }); ``` ### 3. Render AI-Generated Specs ```tsx function Dashboard({ spec }) { return ; } ``` **That's it.** AI generates JSON, you render it safely. --- ## Packages | Package | Description | | --------------------------- | ---------------------------------------------------------------------- | | `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities | | `@json-render/react` | React renderer, contexts, hooks | | `@json-render/vue` | Vue 3 renderer, composables, providers | | `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity | | `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts | | `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) | | `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (19 built-in components) | | `@json-render/react-native` | React Native renderer with standard mobile components | | `@json-render/remotion` | Remotion video renderer, timeline schema | | `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs | | `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs | | `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori | | `@json-render/codegen` | Utilities for generating code from json-render UI trees | | `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` | | `@json-render/zustand` | Zustand adapter for `StateStore` | | `@json-render/jotai` | Jotai adapter for `StateStore` | | `@json-render/xstate` | XState Store (atom) adapter for `StateStore` | | `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code | | `@json-render/yaml` | YAML wire format with streaming parser, edit modes, AI SDK transform | ## Renderers ### React (UI) ```tsx import { defineRegistry, Renderer } from "@json-render/react"; import { schema } from "@json-render/react/schema"; // Flat spec format (root key + elements map) const spec = { root: "card-1", elements: { "card-1": { type: "Card", props: { title: "Hello" }, children: ["button-1"], }, "button-1": { type: "Button", props: { label: "Click me" }, children: [], }, }, }; // defineRegistry creates a type-safe component registry const { registry } = defineRegistry(catalog, { components }); ; ``` ### Vue (UI) ```typescript import { h } from "vue"; import { defineRegistry, Renderer } from "@json-render/vue"; import { schema } from "@json-render/vue/schema"; const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => h("div", { class: "card" }, [h("h3", null, props.title), children]), Button: ({ props, emit }) => h("button", { onClick: () => emit("press") }, props.label), }, }); // In your Vue component template: // ``` ### Svelte (UI) ```typescript import { defineRegistry, Renderer } from "@json-render/svelte"; import { schema } from "@json-render/svelte/schema"; const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => /* Svelte 5 snippet */, Button: ({ props, emit }) => /* Svelte 5 snippet */, }, }); // In your Svelte component: // ``` ### Solid (UI) ```tsx import { defineRegistry, Renderer } from "@json-render/solid"; import { schema } from "@json-render/solid/schema"; const { registry } = defineRegistry(catalog, { components: { Card: (renderProps) =>
{renderProps.children}
, Button: (renderProps) => ( ), }, }); ; ``` ### shadcn/ui (Web) ```tsx import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { defineRegistry, Renderer } from "@json-render/react"; import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog"; import { shadcnComponents } from "@json-render/shadcn"; // Pick components from the 36 standard definitions const catalog = defineCatalog(schema, { components: { Card: shadcnComponentDefinitions.Card, Stack: shadcnComponentDefinitions.Stack, Heading: shadcnComponentDefinitions.Heading, Button: shadcnComponentDefinitions.Button, }, actions: {}, }); // Use matching implementations const { registry } = defineRegistry(catalog, { components: { Card: shadcnComponents.Card, Stack: shadcnComponents.Stack, Heading: shadcnComponents.Heading, Button: shadcnComponents.Button, }, }); ; ``` ### React Native (Mobile) ```tsx import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react-native/schema"; import { standardComponentDefinitions, standardActionDefinitions, } from "@json-render/react-native/catalog"; import { defineRegistry, Renderer } from "@json-render/react-native"; // 25+ standard components included const catalog = defineCatalog(schema, { components: { ...standardComponentDefinitions }, actions: standardActionDefinitions, }); const { registry } = defineRegistry(catalog, { components: {} }); ; ``` ### Remotion (Video) ```tsx import { Player } from "@remotion/player"; import { Renderer, schema, standardComponentDefinitions, } from "@json-render/remotion"; // Timeline spec format const spec = { composition: { id: "video", fps: 30, width: 1920, height: 1080, durationInFrames: 300, }, tracks: [{ id: "main", name: "Main", type: "video", enabled: true }], clips: [ { id: "clip-1", trackId: "main", component: "TitleCard", props: { title: "Hello" }, from: 0, durationInFrames: 90, }, ], audio: { tracks: [] }, }; ; ``` ### React PDF (Documents) ```typescript import { renderToBuffer } from "@json-render/react-pdf"; const spec = { root: "doc", elements: { doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"], }, "page-1": { type: "Page", props: { size: "A4" }, children: ["heading-1", "table-1"], }, "heading-1": { type: "Heading", props: { text: "Invoice #1234", level: "h1" }, children: [], }, "table-1": { type: "Table", props: { columns: [ { header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }, ], rows: [ ["Widget A", "$10.00"], ["Widget B", "$25.00"], ], }, children: [], }, }, }; // Render to buffer, stream, or file const buffer = await renderToBuffer(spec); ``` ### React Email (Email) ```typescript import { renderToHtml } from "@json-render/react-email"; import { schema, standardComponentDefinitions } from "@json-render/react-email"; import { defineCatalog } from "@json-render/core"; const catalog = defineCatalog(schema, { components: standardComponentDefinitions, }); const spec = { root: "html-1", elements: { "html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"], }, "head-1": { type: "Head", props: {}, children: [] }, "body-1": { type: "Body", props: { style: { backgroundColor: "#f6f9fc" } }, children: ["container-1"], }, "container-1": { type: "Container", props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" }, }, children: ["heading-1", "text-1"], }, "heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] }, "text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [], }, }, }; const html = await renderToHtml(spec); ``` ### Image (SVG/PNG) ```typescript import { renderToPng } from "@json-render/image/render"; const spec = { root: "frame", elements: { frame: { type: "Frame", props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" }, children: ["heading"], }, heading: { type: "Heading", props: { text: "Hello World", level: "h1", color: "#ffffff" }, children: [], }, }, }; // Render to PNG (requires @resvg/resvg-js) const png = await renderToPng(spec, { fonts }); // Or render to SVG string import { renderToSvg } from "@json-render/image/render"; const svg = await renderToSvg(spec, { fonts }); ``` ### Three.js (3D) ```tsx import { defineCatalog } from "@json-render/core"; import { schema, defineRegistry } from "@json-render/react"; import { threeComponentDefinitions, threeComponents, ThreeCanvas, } from "@json-render/react-three-fiber"; const catalog = defineCatalog(schema, { components: { Box: threeComponentDefinitions.Box, Sphere: threeComponentDefinitions.Sphere, AmbientLight: threeComponentDefinitions.AmbientLight, DirectionalLight: threeComponentDefinitions.DirectionalLight, OrbitControls: threeComponentDefinitions.OrbitControls, }, actions: {}, }); const { registry } = defineRegistry(catalog, { components: { Box: threeComponents.Box, Sphere: threeComponents.Sphere, AmbientLight: threeComponents.AmbientLight, DirectionalLight: threeComponents.DirectionalLight, OrbitControls: threeComponents.OrbitControls, }, }); ; ``` ## Features ### Streaming (SpecStream) Stream AI responses progressively: ```typescript import { createSpecStreamCompiler } from "@json-render/core"; const compiler = createSpecStreamCompiler(); // Process chunks as they arrive const { result, newPatches } = compiler.push(chunk); setSpec(result); // Update UI with partial result // Get final result const finalSpec = compiler.getResult(); ``` ### AI Prompt Generation Generate system prompts from your catalog: ```typescript const systemPrompt = catalog.prompt(); // Includes component descriptions, props schemas, available actions ``` ### Conditional Visibility ```json { "type": "Alert", "props": { "message": "Error occurred" }, "visible": [ { "$state": "/form/hasError" }, { "$state": "/form/errorDismissed", "not": true } ] } ``` ### Dynamic Props Any prop value can be data-driven using expressions: ```json { "type": "Icon", "props": { "name": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "home", "$else": "home-outline" }, "color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" } } } ``` Expression forms: - **`{ "$state": "/state/key" }`** - reads a value from the state model - **`{ "$cond": , "$then": , "$else": }`** - evaluates a condition and picks a branch - **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates state values into strings - **`{ "$computed": "fn", "args": { ... } }`** - calls a registered function with resolved args ### Actions Components can trigger actions, including the built-in `setState` action: ```json { "type": "Pressable", "props": { "action": "setState", "actionParams": { "statePath": "/activeTab", "value": "home" } }, "children": ["home-icon"] } ``` The `setState` action updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions. ### State Watchers React to state changes by triggering actions: ```json { "type": "Select", "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] }, "watch": { "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } } } } ``` `watch` is a top-level field on elements (sibling of `type`/`props`/`children`). Watchers fire when the watched value changes, not on initial render. --- ## Demo ```bash git clone https://github.com/vercel-labs/json-render cd json-render pnpm install pnpm dev ``` - http://json-render.localhost:1355 - Docs & Playground - http://dashboard-demo.json-render.localhost:1355 - Example Dashboard - http://react-email-demo.json-render.localhost:1355 - React Email Example - http://remotion-demo.json-render.localhost:1355 - Remotion Video Example - Chat Example: run `pnpm dev` in `examples/chat` - Svelte Example: run `pnpm dev` in `examples/svelte` or `examples/svelte-chat` - Vue Example: run `pnpm dev` in `examples/vue` - Vite Renderers (React + Vue + Svelte + Solid): run `pnpm dev` in `examples/vite-renderers` - React Native example: run `npx expo start` in `examples/react-native` ## How It Works ```mermaid flowchart LR A[User Prompt] --> B[AI + Catalog] B --> C[JSON Spec] C --> D[Renderer] B -.- E([guardrailed]) C -.- F([predictable]) D -.- G([streamed]) ``` 1. **Define the guardrails** - what components, actions, and data bindings AI can use 2. **Prompt** - describe what you want in natural language 3. **AI generates JSON** - output is always predictable, constrained to your catalog 4. **Render fast** - stream and render progressively as the model responds ## License Apache-2.0 ================================================ FILE: apps/web/.env.example ================================================ # Vercel AI Gateway # Automatically authenticated when deployed on Vercel # For local development, get your key from https://vercel.com/ai-gateway AI_GATEWAY_API_KEY= # AI Model Configuration # Override the default model used for UI generation # Default: anthropic/claude-haiku-4.5 AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5 # Vercel KV (Rate Limiting) # Automatically populated when you add Vercel KV to your project KV_REST_API_URL= KV_REST_API_TOKEN= # Rate Limiting # RATE_LIMIT_PER_MINUTE=10 # RATE_LIMIT_PER_DAY=100 ================================================ FILE: apps/web/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js .yarn/install-state.gz # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # env files (can opt-in for commiting if needed) .env* !.env.example # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts .env*.local ================================================ FILE: apps/web/CHANGELOG.md ================================================ # web ## 0.1.9 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 - @json-render/codegen@0.14.1 - @json-render/react@0.14.1 - @json-render/yaml@0.14.1 ## 0.1.8 ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 - @json-render/yaml@0.14.0 - @json-render/codegen@0.14.0 - @json-render/react@0.14.0 ## 0.1.7 ### Patch Changes - Updated dependencies [5b32de8] - @json-render/core@0.13.0 - @json-render/codegen@0.13.0 - @json-render/react@0.13.0 ## 0.1.6 ### Patch Changes - Updated dependencies [54a1ecf] - @json-render/core@0.12.1 - @json-render/codegen@0.12.1 - @json-render/react@0.12.1 ## 0.1.5 ### Patch Changes - Updated dependencies [63c339b] - @json-render/core@0.12.0 - @json-render/codegen@0.12.0 - @json-render/react@0.12.0 ## 0.1.4 ### Patch Changes - Updated dependencies [3f1e71e] - @json-render/core@0.11.0 - @json-render/codegen@0.11.0 - @json-render/react@0.11.0 ## 0.1.3 ### Patch Changes - Updated dependencies [9cef4e9] - @json-render/core@0.10.0 - @json-render/react@0.10.0 - @json-render/codegen@0.10.0 ## 0.1.2 ### Patch Changes - Updated dependencies [b103676] - @json-render/react@0.9.1 - @json-render/core@0.9.1 - @json-render/codegen@0.9.1 ## 0.1.1 ### Patch Changes - Updated dependencies [1d755c1] - @json-render/core@0.9.0 - @json-render/react@0.9.0 - @json-render/codegen@0.9.0 ================================================ FILE: apps/web/README.md ================================================ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). ## Getting Started First, run the development server: ```bash npm run dev # or yarn dev # or pnpm dev # or bun dev ``` Open [http://json-render.localhost:1355](http://json-render.localhost:1355) with your browser to see the result. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ================================================ FILE: apps/web/app/(main)/docs/a2ui/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/a2ui") # A2UI Integration Use `@json-render/core` to support [A2UI](https://a2ui.org) natively.

Concept: This page demonstrates how json-render can support A2UI. The examples are illustrative and may require adaptation for production use.

## Native A2UI Support `@json-render/core` is schema-agnostic. Define a catalog that matches A2UI's format and build a renderer that understands it - no conversion layer needed. ## Example A2UI Message A2UI uses an adjacency list model - a flat list of components with ID references. This makes it easy to patch individual components: ```json { "surfaceUpdate": { "surfaceId": "main", "components": [ { "id": "header", "component": { "Text": { "text": {"literalString": "Book Your Table"}, "usageHint": "h1" } } }, { "id": "date-picker", "component": { "DateTimeInput": { "label": {"literalString": "Select Date"}, "value": {"path": "/reservation/date"}, "enableDate": true } } }, { "id": "submit-btn", "component": { "Button": { "child": "submit-text", "action": {"name": "confirm_booking"} } } }, { "id": "submit-text", "component": { "Text": {"text": {"literalString": "Confirm Reservation"}} } } ] } } ``` ## Define the A2UI Catalog ```typescript import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; import { z } from 'zod'; // A2UI BoundValue schema const BoundString = z.object({ literalString: z.string().optional(), path: z.string().optional(), }).refine(d => d.literalString || d.path); // A2UI children schema const Children = z.object({ explicitList: z.array(z.string()).optional(), template: z.object({ dataBinding: z.string(), componentId: z.string(), }).optional(), }).refine(d => d.explicitList || d.template); export const a2uiCatalog = defineCatalog(schema, { components: { Text: { description: 'Displays text content', props: z.object({ text: BoundString, usageHint: z.enum(['h1', 'h2', 'h3', 'body', 'caption']).optional(), }), }, Button: { description: 'Interactive button', props: z.object({ child: z.string(), action: z.object({ name: z.string(), context: z.array(z.object({ key: z.string(), value: BoundString, })).optional(), }).optional(), }), }, DateTimeInput: { description: 'Date/time picker', props: z.object({ label: BoundString.optional(), value: BoundString.optional(), enableDate: z.boolean().optional(), enableTime: z.boolean().optional(), }), }, Column: { description: 'Vertical layout', props: z.object({ children: Children, }), }, Row: { description: 'Horizontal layout', props: z.object({ children: Children, }), }, // Add more A2UI standard components... }, }); ``` ## Define the A2UI Schema Define the schema for A2UI message types: ```typescript import { z } from 'zod'; // Component instance in the adjacency list const A2UIComponent = z.object({ id: z.string(), component: z.record(z.record(z.unknown())), }); // Surface update message const SurfaceUpdate = z.object({ surfaceId: z.string().optional(), components: z.array(A2UIComponent), }); // State model update message const StateModelUpdate = z.object({ surfaceId: z.string().optional(), path: z.string().optional(), contents: z.array(z.object({ key: z.string(), valueString: z.string().optional(), valueNumber: z.number().optional(), valueBoolean: z.boolean().optional(), valueMap: z.array(z.unknown()).optional(), })), }); // Begin rendering message const BeginRendering = z.object({ surfaceId: z.string().optional(), root: z.string(), catalogId: z.string().optional(), }); // Complete A2UI message schema export const A2UIMessage = z.object({ surfaceUpdate: SurfaceUpdate.optional(), dataModelUpdate: StateModelUpdate.optional(), beginRendering: BeginRendering.optional(), deleteSurface: z.object({ surfaceId: z.string() }).optional(), }); ``` ## Build an A2UI Renderer Create a renderer that processes the A2UI adjacency list format: ```tsx import { a2uiCatalog } from './catalog'; // Component registry const components = { Text: ({ text, usageHint }) => { const Tag = usageHint?.startsWith('h') ? usageHint : 'p'; return {text}; }, Button: ({ children, action, onAction }) => ( ), DateTimeInput: ({ label, value, onChange }) => ( ), Column: ({ children }) =>
{children}
, Row: ({ children }) =>
{children}
, }; // Render A2UI surface export function renderA2UI( componentMap: Map, dataModel: Record, rootId: string, onAction?: (action: any) => void ) { function resolveBoundValue(bound: any) { if (!bound) return undefined; if (bound.literalString) return bound.literalString; if (bound.path) { const parts = bound.path.replace(/^\//, '').split('/'); let value = dataModel; for (const p of parts) value = value?.[p]; return value; } } function render(id: string): React.ReactNode { const comp = componentMap.get(id); if (!comp) return null; const [type, props] = Object.entries(comp.component)[0]; const Component = components[type]; if (!Component) return null; // Resolve props const resolved: any = {}; for (const [key, val] of Object.entries(props as any)) { if (key === 'child') { resolved.children = render(val as string); } else if (key === 'children' && val?.explicitList) { resolved.children = val.explicitList.map(render); } else if (val && typeof val === 'object' && ('literalString' in val || 'path' in val)) { resolved[key] = resolveBoundValue(val); } else { resolved[key] = val; } } return ; } return render(rootId); } ``` ## Usage ```tsx const [components] = useState(() => new Map()); const [dataModel, setDataModel] = useState({}); const [rootId, setRootId] = useState(null); // Process A2UI messages function handleMessage(msg: any) { if (msg.surfaceUpdate) { for (const comp of msg.surfaceUpdate.components) { components.set(comp.id, comp); } } if (msg.dataModelUpdate) { setDataModel(prev => ({ ...prev, ...msg.dataModelUpdate.contents })); } if (msg.beginRendering) { setRootId(msg.beginRendering.root); } } // Render {rootId && renderA2UI(components, dataModel, rootId, handleAction)} ``` ## Next Learn about [Adaptive Cards integration](/docs/adaptive-cards) for another UI protocol. ================================================ FILE: apps/web/app/(main)/docs/adaptive-cards/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/adaptive-cards") # Adaptive Cards Integration Use json-render to render [Microsoft Adaptive Cards](https://adaptivecards.io) natively.

Concept: This page demonstrates how json-render can support Adaptive Cards. The examples are illustrative and may require adaptation for production use.

## Adaptive Cards Overview Adaptive Cards is a JSON-based format for platform-agnostic UI snippets. Cards have a `body` array of elements and an optional `actions` array for interactive buttons. ### Example Adaptive Card ```json { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.5", "body": [ { "type": "TextBlock", "text": "Hello, Adaptive Cards!", "size": "large", "weight": "bolder" }, { "type": "Image", "url": "https://example.com/image.png", "altText": "Example image" }, { "type": "Container", "items": [ { "type": "TextBlock", "text": "This is inside a container", "wrap": true } ] }, { "type": "ColumnSet", "columns": [ { "type": "Column", "width": "auto", "items": [ { "type": "TextBlock", "text": "Column 1" } ] }, { "type": "Column", "width": "stretch", "items": [ { "type": "TextBlock", "text": "Column 2" } ] } ] }, { "type": "Input.Text", "id": "userInput", "placeholder": "Enter your name", "label": "Name" } ], "actions": [ { "type": "Action.Submit", "title": "Submit" }, { "type": "Action.OpenUrl", "title": "Learn More", "url": "https://adaptivecards.io" } ] } ``` ## Creating an Adaptive Cards Catalog Define a catalog matching the Adaptive Cards element types: ```typescript import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; import { z } from 'zod'; // Common Adaptive Cards properties const Spacing = z.enum(['none', 'small', 'default', 'medium', 'large', 'extraLarge', 'padding']); const HorizontalAlignment = z.enum(['left', 'center', 'right']); const VerticalAlignment = z.enum(['top', 'center', 'bottom']); const FontSize = z.enum(['small', 'default', 'medium', 'large', 'extraLarge']); const FontWeight = z.enum(['lighter', 'default', 'bolder']); const ImageSize = z.enum(['auto', 'stretch', 'small', 'medium', 'large']); const ImageStyle = z.enum(['default', 'person']); // Base element properties shared by most elements const BaseElement = { id: z.string().optional(), isVisible: z.boolean().optional(), separator: z.boolean().optional(), spacing: Spacing.optional(), }; export const adaptiveCardsCatalog = defineCatalog(schema, { components: { // Root card AdaptiveCard: { description: 'Root Adaptive Card container', props: z.object({ version: z.string(), body: z.array(z.unknown()).optional(), actions: z.array(z.unknown()).optional(), fallbackText: z.string().optional(), minHeight: z.string().optional(), rtl: z.boolean().optional(), verticalContentAlignment: VerticalAlignment.optional(), }), }, // Elements TextBlock: { description: 'Displays text with formatting options', props: z.object({ ...BaseElement, text: z.string(), color: z.enum(['default', 'dark', 'light', 'accent', 'good', 'warning', 'attention']).optional(), fontType: z.enum(['default', 'monospace']).optional(), horizontalAlignment: HorizontalAlignment.optional(), isSubtle: z.boolean().optional(), maxLines: z.number().optional(), size: FontSize.optional(), weight: FontWeight.optional(), wrap: z.boolean().optional(), }), }, Image: { description: 'Displays an image', props: z.object({ ...BaseElement, url: z.string(), altText: z.string().optional(), backgroundColor: z.string().optional(), height: z.string().optional(), width: z.string().optional(), horizontalAlignment: HorizontalAlignment.optional(), size: ImageSize.optional(), style: ImageStyle.optional(), }), }, Container: { description: 'Groups elements together', props: z.object({ ...BaseElement, items: z.array(z.unknown()), style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(), verticalContentAlignment: VerticalAlignment.optional(), bleed: z.boolean().optional(), minHeight: z.string().optional(), }), }, ColumnSet: { description: 'Arranges columns horizontally', props: z.object({ ...BaseElement, columns: z.array(z.unknown()), horizontalAlignment: HorizontalAlignment.optional(), minHeight: z.string().optional(), }), }, Column: { description: 'A column within a ColumnSet', props: z.object({ ...BaseElement, items: z.array(z.unknown()).optional(), width: z.union([z.string(), z.number()]).optional(), style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(), verticalContentAlignment: VerticalAlignment.optional(), }), }, FactSet: { description: 'Displays a series of facts as key/value pairs', props: z.object({ ...BaseElement, facts: z.array(z.object({ title: z.string(), value: z.string(), })), }), }, // Inputs 'Input.Text': { description: 'Text input field', props: z.object({ ...BaseElement, id: z.string(), isMultiline: z.boolean().optional(), maxLength: z.number().optional(), placeholder: z.string().optional(), label: z.string().optional(), value: z.string().optional(), style: z.enum(['text', 'tel', 'url', 'email', 'password']).optional(), isRequired: z.boolean().optional(), errorMessage: z.string().optional(), }), }, 'Input.Number': { description: 'Number input field', props: z.object({ ...BaseElement, id: z.string(), max: z.number().optional(), min: z.number().optional(), placeholder: z.string().optional(), label: z.string().optional(), value: z.number().optional(), isRequired: z.boolean().optional(), errorMessage: z.string().optional(), }), }, 'Input.Toggle': { description: 'Toggle/checkbox input', props: z.object({ ...BaseElement, id: z.string(), title: z.string(), label: z.string().optional(), value: z.string().optional(), valueOff: z.string().optional(), valueOn: z.string().optional(), isRequired: z.boolean().optional(), }), }, 'Input.ChoiceSet': { description: 'Dropdown or radio/checkbox group', props: z.object({ ...BaseElement, id: z.string(), choices: z.array(z.object({ title: z.string(), value: z.string(), })), isMultiSelect: z.boolean().optional(), style: z.enum(['compact', 'expanded']).optional(), label: z.string().optional(), value: z.string().optional(), placeholder: z.string().optional(), isRequired: z.boolean().optional(), }), }, // Actions 'Action.OpenUrl': { description: 'Opens a URL', props: z.object({ title: z.string().optional(), url: z.string(), iconUrl: z.string().optional(), }), }, 'Action.Submit': { description: 'Submits input data', props: z.object({ title: z.string().optional(), data: z.unknown().optional(), iconUrl: z.string().optional(), }), }, 'Action.ShowCard': { description: 'Shows a card inline', props: z.object({ title: z.string().optional(), card: z.unknown(), iconUrl: z.string().optional(), }), }, 'Action.Execute': { description: 'Universal action for bots', props: z.object({ title: z.string().optional(), verb: z.string().optional(), data: z.unknown().optional(), iconUrl: z.string().optional(), }), }, }, }); ``` ## Building an Adaptive Cards Renderer Create a renderer that processes Adaptive Cards JSON. See the [A2UI integration](/docs/a2ui) page for a similar pattern. The key is mapping each Adaptive Card element type to a React component, resolving nested `items` and `columns` arrays recursively. ## Usage Example Render an Adaptive Card and handle actions: ```tsx 'use client'; import { AdaptiveCardRenderer } from './adaptive-card-renderer'; const card = { type: 'AdaptiveCard' as const, version: '1.5', body: [ { type: 'TextBlock', text: 'Contact Form', size: 'large', weight: 'bolder', }, { type: 'Input.Text', id: 'name', label: 'Your Name', placeholder: 'Enter your name', }, { type: 'Input.Text', id: 'message', label: 'Message', placeholder: 'Enter your message', isMultiline: true, }, ], actions: [ { type: 'Action.Submit', title: 'Send', data: { action: 'submitForm' }, }, ], }; export function ContactCard() { const handleAction = (action: any, inputData: Record) => { console.log('Action:', action); console.log('Input data:', inputData); // Send to your backend fetch('/api/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, data: inputData }), }); }; return ; } ``` ## Handling Action.Execute for Bots For bot scenarios, handle `Action.Execute` with the verb and data: ```typescript interface ActionExecutePayload { action: { type: 'Action.Execute'; verb: string; data?: unknown; }; inputs: Record; } async function handleBotAction(payload: ActionExecutePayload) { const response = await fetch('/api/bot/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ verb: payload.action.verb, data: payload.action.data, inputs: payload.inputs, }), }); // Bot may return a new card to render const result = await response.json(); if (result.card) { return result.card; // New AdaptiveCard to render } } ``` ## Next Learn about [A2UI integration](/docs/a2ui) for another agent-driven UI protocol. ================================================ FILE: apps/web/app/(main)/docs/ag-ui/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/ag-ui") # AG-UI Integration Use json-render to support [AG-UI](https://docs.copilotkit.ai/ag-ui) (Agent User Interaction Protocol) from CopilotKit.

Concept: This page demonstrates how json-render can support AG-UI. The examples are illustrative and may require adaptation for production use.

## What is AG-UI? AG-UI is an open protocol for connecting AI agents to user interfaces. It provides a standardized way for agents to render UI components, handle user input, and manage state. The protocol uses events streamed over HTTP to update the UI in real-time. ## AG-UI Event Types AG-UI defines several event types for agent-UI communication: - `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` — Streaming text messages - `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` — Tool/function calls - `STATE_SNAPSHOT` / `STATE_DELTA` — State updates - `CUSTOM` — Custom events for UI rendering ### Example AG-UI Event Stream ```json {"type": "RUN_STARTED", "threadId": "thread-123", "runId": "run-456"} {"type": "TEXT_MESSAGE_START", "messageId": "msg-1", "role": "assistant"} {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-1", "delta": "Here's a dashboard for you:"} {"type": "TEXT_MESSAGE_END", "messageId": "msg-1"} {"type": "TOOL_CALL_START", "toolCallId": "tc-1", "toolCallName": "render_ui"} {"type": "TOOL_CALL_ARGS", "toolCallId": "tc-1", "delta": "{\"component\": \"Dashboard\", \"props\": {\"title\": \"Sales\"}}"} {"type": "TOOL_CALL_END", "toolCallId": "tc-1"} {"type": "RUN_FINISHED"} ``` ## Define the AG-UI Schema Define schemas for AG-UI event types: ```typescript import { z } from 'zod'; // Base event schema const BaseEvent = z.object({ type: z.string(), timestamp: z.number().optional(), }); // Text message events const TextMessageStart = BaseEvent.extend({ type: z.literal('TEXT_MESSAGE_START'), messageId: z.string(), role: z.enum(['user', 'assistant']), }); const TextMessageContent = BaseEvent.extend({ type: z.literal('TEXT_MESSAGE_CONTENT'), messageId: z.string(), delta: z.string(), }); const TextMessageEnd = BaseEvent.extend({ type: z.literal('TEXT_MESSAGE_END'), messageId: z.string(), }); // Tool call events const ToolCallStart = BaseEvent.extend({ type: z.literal('TOOL_CALL_START'), toolCallId: z.string(), toolCallName: z.string(), parentMessageId: z.string().optional(), }); const ToolCallArgs = BaseEvent.extend({ type: z.literal('TOOL_CALL_ARGS'), toolCallId: z.string(), delta: z.string(), }); const ToolCallEnd = BaseEvent.extend({ type: z.literal('TOOL_CALL_END'), toolCallId: z.string(), }); // State events const StateSnapshot = BaseEvent.extend({ type: z.literal('STATE_SNAPSHOT'), snapshot: z.record(z.unknown()), }); const StateDelta = BaseEvent.extend({ type: z.literal('STATE_DELTA'), delta: z.array(z.object({ op: z.enum(['add', 'remove', 'replace']), path: z.string(), value: z.unknown().optional(), })), }); // Custom event for UI components const CustomEvent = BaseEvent.extend({ type: z.literal('CUSTOM'), name: z.string(), value: z.unknown(), }); // Run lifecycle events const RunStarted = BaseEvent.extend({ type: z.literal('RUN_STARTED'), threadId: z.string(), runId: z.string(), }); const RunFinished = BaseEvent.extend({ type: z.literal('RUN_FINISHED'), }); const RunError = BaseEvent.extend({ type: z.literal('RUN_ERROR'), message: z.string(), code: z.string().optional(), }); // Union of all events export const AGUIEvent = z.discriminatedUnion('type', [ TextMessageStart, TextMessageContent, TextMessageEnd, ToolCallStart, ToolCallArgs, ToolCallEnd, StateSnapshot, StateDelta, CustomEvent, RunStarted, RunFinished, RunError, ]); export type AGUIEvent = z.infer; ``` ## Define the AG-UI Catalog Create a catalog for UI components that agents can render: ```typescript import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; import { z } from 'zod'; export const aguiCatalog = defineCatalog(schema, { components: { Container: { description: 'A container for grouping elements', props: z.object({ direction: z.enum(['row', 'column']).optional(), gap: z.enum(['none', 'sm', 'md', 'lg']).optional(), padding: z.enum(['none', 'sm', 'md', 'lg']).optional(), }), }, Card: { description: 'A card with optional title', props: z.object({ title: z.string().optional(), description: z.string().optional(), }), }, Text: { description: 'Text content', props: z.object({ content: z.string(), variant: z.enum(['body', 'heading', 'caption', 'code']).optional(), }), }, Metric: { description: 'Displays a metric value', props: z.object({ label: z.string(), value: z.union([z.string(), z.number()]), change: z.number().optional(), format: z.enum(['number', 'currency', 'percent']).optional(), }), }, Button: { description: 'Interactive button', props: z.object({ label: z.string(), variant: z.enum(['primary', 'secondary', 'outline', 'ghost']).optional(), disabled: z.boolean().optional(), }), }, Alert: { description: 'Alert message', props: z.object({ message: z.string(), type: z.enum(['info', 'success', 'warning', 'error']).optional(), }), }, // Add more components... }, actions: { submit: { description: 'Submit form data', params: z.object({ formId: z.string() }), }, navigate: { description: 'Navigate to a URL', params: z.object({ url: z.string() }), }, callback: { description: 'Trigger a callback to the agent', params: z.object({ name: z.string(), data: z.record(z.unknown()).optional(), }), }, }, }); ``` ## Build an AG-UI Event Processor Process AG-UI events and render UI components: ```tsx 'use client'; import React, { useState, useCallback } from 'react'; import { AGUIEvent } from './schema'; interface AGUIState { messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; }>; toolCalls: Map; state: Record; isRunning: boolean; } export function useAGUI() { const [aguiState, setAGUIState] = useState({ messages: [], toolCalls: new Map(), state: {}, isRunning: false, }); const processEvent = useCallback((event: AGUIEvent) => { switch (event.type) { case 'RUN_STARTED': setAGUIState(prev => ({ ...prev, isRunning: true })); break; case 'RUN_FINISHED': setAGUIState(prev => ({ ...prev, isRunning: false })); break; case 'TEXT_MESSAGE_START': setAGUIState(prev => ({ ...prev, messages: [...prev.messages, { id: event.messageId, role: event.role, content: '', }], })); break; case 'TEXT_MESSAGE_CONTENT': setAGUIState(prev => ({ ...prev, messages: prev.messages.map(msg => msg.id === event.messageId ? { ...msg, content: msg.content + event.delta } : msg ), })); break; case 'TOOL_CALL_START': setAGUIState(prev => { const toolCalls = new Map(prev.toolCalls); toolCalls.set(event.toolCallId, { name: event.toolCallName, args: '' }); return { ...prev, toolCalls }; }); break; case 'TOOL_CALL_ARGS': setAGUIState(prev => { const toolCalls = new Map(prev.toolCalls); const tc = toolCalls.get(event.toolCallId); if (tc) { toolCalls.set(event.toolCallId, { ...tc, args: tc.args + event.delta }); } return { ...prev, toolCalls }; }); break; case 'STATE_SNAPSHOT': setAGUIState(prev => ({ ...prev, state: event.snapshot })); break; } }, []); return { state: aguiState, processEvent }; } ``` ## Usage Example ```tsx 'use client'; import { useAGUI } from './use-agui'; import { renderToolCallUI } from './renderer'; export function AGUIChat() { const { state, processEvent } = useAGUI(); async function startRun(prompt: string) { const response = await fetch('/api/agent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }), }); const reader = response.body?.getReader(); const decoder = new TextDecoder(); while (reader) { const { done, value } = await reader.read(); if (done) break; const lines = decoder.decode(value).split('\n').filter(Boolean); for (const line of lines) { const event = JSON.parse(line); processEvent(event); } } } return (
{state.messages.map(msg => (
{msg.content}
))} {Array.from(state.toolCalls.values()).map((tc, i) => (
{renderToolCallUI(tc)}
))}
{ e.preventDefault(); const input = e.currentTarget.querySelector('input'); if (input?.value) { startRun(input.value); input.value = ''; } }}>
); } ``` ## Next Learn about [OpenAPI integration](/docs/openapi) for rendering forms from API schemas. ================================================ FILE: apps/web/app/(main)/docs/ai-sdk/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/ai-sdk") # AI SDK Integration Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless streaming. json-render supports two modes: **Standalone** (standalone UI) and **Inline** (UI embedded in conversation). See [Generation Modes](/docs/generation-modes) for a detailed comparison. ## Installation ```bash npm install ai @ai-sdk/react ``` ## Standalone Mode In standalone mode, the AI outputs only JSONL patches. The entire response is a UI spec with no prose. This is the default mode and is ideal for playgrounds, builders, and dashboard generators. ### API Route ```typescript // app/api/generate/route.ts import { streamText } from "ai"; import { catalog } from "@/lib/catalog"; export async function POST(req: Request) { const { prompt, currentTree } = await req.json(); const systemPrompt = catalog.prompt(); // Optionally include current UI state for context const contextPrompt = currentTree ? `\n\nCurrent UI state:\n${JSON.stringify(currentTree, null, 2)}` : ""; const result = streamText({ model: yourModel, system: systemPrompt + contextPrompt, prompt, }); return result.toTextStreamResponse(); } ``` ### Client Use `useUIStream` on the client to compile the JSONL stream into a spec: ```tsx "use client"; import { useUIStream, Renderer } from "@json-render/react"; function GenerativeUI() { const { spec, isStreaming, error, send } = useUIStream({ api: "/api/generate", }); return (
{error &&

{error.message}

}
); } ``` ## Inline Mode In inline mode, the AI responds conversationally and includes JSONL patches inline. Text-only replies are allowed when no UI is needed. This is ideal for chatbots, copilots, and educational assistants. ### API Route Use `pipeJsonRender` to separate text from JSONL patches in the stream. Patches are emitted as data parts that the client can pick up. ```typescript // app/api/chat/route.ts import { streamText } from "ai"; import { pipeJsonRender } from "@json-render/core"; import { createUIMessageStream, createUIMessageStreamResponse, } from "ai"; import { catalog } from "@/lib/catalog"; export async function POST(req: Request) { const { messages } = await req.json(); const result = streamText({ model: yourModel, system: catalog.prompt({ mode: "inline" }), messages, }); const stream = createUIMessageStream({ execute: async ({ writer }) => { writer.merge(pipeJsonRender(result.toUIMessageStream())); }, }); return createUIMessageStreamResponse({ stream }); } ``` ### Client Use `useChat` from the AI SDK and `useJsonRenderMessage` from json-render to extract the spec from each message: ```tsx "use client"; import { useChat } from "@ai-sdk/react"; import { useJsonRenderMessage, Renderer } from "@json-render/react"; function Chat() { const { messages, input, handleInputChange, handleSubmit } = useChat({ api: "/api/chat", }); return (
{messages.map((msg) => ( ))}
); } function ChatMessage({ message }: { message: { parts: Array<{ type: string; text?: string; data?: unknown }> } }) { const { spec, text, hasSpec } = useJsonRenderMessage(message.parts); return (
{text &&

{text}

} {hasSpec && spec && ( )}
); } ``` ## Prompt Engineering The `catalog.prompt()` method creates an optimized system prompt that: - Lists all available components and their props - Describes available actions - Specifies the expected output format (JSONL-only or text + JSONL depending on mode) - Includes examples for better generation ### Custom Rules Pass custom rules to tailor AI behavior: ```typescript const systemPrompt = catalog.prompt({ customRules: [ "Always use Card components for grouping related content", "Prefer horizontal layouts (Row) for metrics", "Use consistent spacing with padding=\"md\"", ], }); ``` ### Inline Mode Prompt ```typescript const inlinePrompt = catalog.prompt({ mode: "inline" }); ``` In inline mode, the prompt instructs the AI to respond conversationally first, then include JSONL patches on their own lines when UI is needed. Text-only replies are allowed. ## Which Mode?
Standalone Inline
Output JSONL only Text + JSONL
Text-only replies No Yes
System prompt catalog.prompt() {"catalog.prompt({ mode: \"inline\" })"}
Stream utility useUIStream pipeJsonRender + useJsonRenderMessage
Use case Playgrounds, builders Chatbots, copilots
Learn more in the [Generation Modes](/docs/generation-modes) guide. ## Next - Learn about [progressive streaming](/docs/streaming) - See the [chat example](https://github.com/vercel-labs/json-render/tree/main/examples/chat) for a complete implementation ================================================ FILE: apps/web/app/(main)/docs/api/codegen/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/codegen") # @json-render/codegen Utilities for generating code from UI trees. ## Tree Traversal ### traverseSpec Walk the UI spec depth-first. ```typescript function traverseSpec( spec: Spec, visitor: TreeVisitor, startKey?: string ): void interface TreeVisitor { (element: UIElement, key: string, depth: number, parent: UIElement | null): void; } ``` ### collectUsedComponents Get all unique component types used in a spec. ```typescript function collectUsedComponents(spec: Spec): Set // Example const components = collectUsedComponents(spec); // Set { 'Card', 'Metric', 'Chart' } ``` ### collectStatePaths Get all state paths referenced in props (statePath, bindPath, etc.). ```typescript function collectStatePaths(spec: Spec): Set // Example const paths = collectStatePaths(spec); // Set { 'analytics/revenue', 'analytics/customers' } ``` ### collectActions Get all action names used in the spec. ```typescript function collectActions(spec: Spec): Set // Example const actions = collectActions(spec); // Set { 'submit_form', 'refresh_data' } ``` ## Serialization ### serializePropValue Serialize a single value to a code string. ```typescript function serializePropValue( value: unknown, options?: SerializeOptions ): { value: string; needsBraces: boolean } // Examples serializePropValue("hello") // { value: '"hello"', needsBraces: false } serializePropValue(42) // { value: '42', needsBraces: true } serializePropValue({ $state: '/user/name' }) // { value: '{ $state: "/user/name" }', needsBraces: true } ``` ### serializeProps Serialize a props object to a JSX attributes string. ```typescript function serializeProps( props: Record, options?: SerializeOptions ): string // Example serializeProps({ title: 'Dashboard', columns: 3, disabled: true }) // 'title="Dashboard" columns={3} disabled' ``` ### escapeString Escape a string for use in code. ```typescript function escapeString( str: string, quotes?: 'single' | 'double' ): string ``` ## Types ### GeneratedFile ```typescript interface GeneratedFile { /** File path relative to project root */ path: string; /** File contents */ content: string; } ``` ### CodeGenerator ```typescript interface CodeGenerator { /** Generate files from a UI spec */ generate(spec: Spec): GeneratedFile[]; } ``` ### SerializeOptions ```typescript interface SerializeOptions { /** Quote style for strings */ quotes?: 'single' | 'double'; /** Indent for objects/arrays */ indent?: number; } ``` ================================================ FILE: apps/web/app/(main)/docs/api/core/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/core") # @json-render/core Core types, schemas, and utilities. ## defineCatalog Creates a type-safe catalog definition with schema validation. ```typescript import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; function defineCatalog( s: T, config: CatalogConfig ): Catalog // Use the React schema for standard UI specs const catalog = defineCatalog(schema, { components: {...}, actions: {...}, }); ``` ### CatalogConfig ```typescript interface CatalogConfig { components: Record; actions?: Record; functions?: Record; } interface ComponentDefinition { props: ZodObject; // Use .nullable() for optional props slots?: string[]; // Named slots (e.g., ["default"]) description?: string; // Help AI understand usage } interface ActionDefinition { params?: ZodObject; description?: string; } interface FunctionDefinition { description?: string; } ``` ### Catalog Instance The returned catalog provides methods for AI prompt generation, validation, and schema export: ```typescript interface Catalog { // Data readonly data: CatalogConfig; // The catalog configuration readonly componentNames: string[]; // List of component names readonly actionNames: string[]; // List of action names // AI Prompt Generation prompt(options?: PromptOptions): string; // Validation validate(spec: unknown): SpecValidationResult; zodSchema(): z.ZodType; // Get the Zod schema for specs // Export jsonSchema(): object; // Export as JSON Schema } interface PromptOptions { system?: string; // Custom system message intro customRules?: string[]; // Additional rules to append mode?: "standalone" | "inline" | "generate" | "chat"; // Output mode (default: "standalone") editModes?: EditMode[]; // Edit modes to document in prompt (default: ["patch"]) } interface SpecValidationResult { success: boolean; data?: T; // Validated spec (if success) error?: z.ZodError; // Validation errors (if failed) } ``` ### Catalog Methods ```typescript // Generate AI system prompt const systemPrompt = catalog.prompt({ customRules: ["Always use Card as root element"], }); // Validate a spec from AI const result = catalog.validate(aiOutput); if (result.success) { render(result.data); } else { console.error(result.error); } // Get Zod schema for custom validation const schema = catalog.zodSchema(); const parsed = schema.safeParse(aiOutput); // Export as JSON Schema (for structured outputs) const jsonSchema = catalog.jsonSchema(); ``` ## Schema System json-render uses a flexible schema system that defines both the AI output format (spec) and what catalogs must provide. Each renderer package provides its own schema (e.g., @json-render/react exports `schema`). ### schema The schema for flat UI element trees. This is exported from @json-render/react. ```typescript import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; // schema defines: // - Spec shape: { root: string, elements: Record } // - Catalog shape: { components: {...}, actions: {...} } const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string() }), slots: ["default"], description: "Container card", }, }, actions: { submit: { params: z.object({ formId: z.string() }), description: "Submit a form", }, }, }); ``` ### SchemaOptions When creating schemas with `defineSchema`, you can pass options: ```typescript interface SchemaOptions { promptTemplate?: PromptTemplate; // Custom AI prompt generator defaultRules?: string[]; // Default rules injected before custom rules in prompts builtInActions?: BuiltInAction[]; // Actions always available at runtime, auto-injected into prompts } interface BuiltInAction { name: string; // Action name (e.g. "setState") description: string; // Human-readable description for the LLM } ``` Built-in actions are injected into prompts as `[built-in]` and are handled by the runtime (e.g. `ActionProvider`) without requiring handlers in `defineRegistry`. The React schema declares `setState`, `pushState`, and `removeState` as built-in. ### defineSchema Create custom schemas for different output formats (e.g., page-based, block-based). ```typescript import { defineSchema } from '@json-render/core'; const mySchema = defineSchema((s) => ({ // What the AI outputs (spec) spec: s.object({ title: s.string(), blocks: s.array(s.object({ type: s.ref("catalog.blocks"), content: s.any(), })), }), // What the catalog must provide catalog: s.object({ blocks: s.map({ props: s.zod(), description: s.string(), }), }), })); ``` ### Schema Builder API The schema builder provides these methods: ```typescript // Primitive types s.string() // String value s.number() // Number value s.boolean() // Boolean value s.any() // Any value // Compound types s.array(item) // Array of items s.object({ ... }) // Object with shape s.record(value) // Record/map with value type // Catalog references (for type safety) s.ref("catalog.components") // Reference to catalog key (becomes enum) s.propsOf("catalog.components") // Props schema from catalog entry // Catalog definitions s.map({ props: s.zod(), ... }) // Map of named entries with shared shape s.zod() // Placeholder for user-provided Zod schema // Modifiers s.optional() // Mark field as optional ``` ## Zod Schemas Pre-built Zod schemas for common json-render types: ### Dynamic Value Schemas ```typescript import { DynamicValueSchema, // string | number | boolean | null | { $state: string } DynamicStringSchema, // string | { $state: string } DynamicNumberSchema, // number | { $state: string } DynamicBooleanSchema, // boolean | { $state: string } } from '@json-render/core'; // Dynamic values can be literals or state path references type DynamicValue = T | { $state: string }; // Example: a prop that can be a literal or bound to state const schema = z.object({ label: DynamicStringSchema, // "Hello" or { $state: "/user/name" } }); ``` ### Visibility Schemas ```typescript import { VisibilityConditionSchema } from '@json-render/core'; // Use in component props that need conditional rendering const schema = z.object({ visible: VisibilityConditionSchema.optional(), }); ``` ### Action Schemas ```typescript import { ActionSchema, // Full action definition ActionConfirmSchema, // Confirmation dialog config ActionOnSuccessSchema, // Success handler config ActionOnErrorSchema, // Error handler config } from '@json-render/core'; ``` ### Validation Schemas ```typescript import { ValidationCheckSchema, // Single validation check ValidationConfigSchema, // Full validation config with checks array } from '@json-render/core'; ``` ## SpecStream SpecStream is json-render's streaming format for progressively building specs from JSONL patches. ### createSpecStreamCompiler Create a streaming compiler that incrementally builds a spec: ```typescript import { createSpecStreamCompiler } from '@json-render/core'; const compiler = createSpecStreamCompiler(); // Process streaming chunks const { result, newPatches } = compiler.push(chunk); // Get final result const spec = compiler.getResult(); // Reset for reuse compiler.reset(); ``` ### compileSpecStream Compile an entire SpecStream string at once: ```typescript import { compileSpecStream } from '@json-render/core'; const jsonl = `{"op":"add","path":"/root","value":{}} {"op":"add","path":"/root/type","value":"Card"}`; const spec = compileSpecStream(jsonl); ``` ### Low-Level Utilities ```typescript import { parseSpecStreamLine, applySpecStreamPatch, } from '@json-render/core'; // Parse a single line const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}'); // Apply patch to object (mutates in place) const obj = {}; applySpecStreamPatch(obj, patch); ``` ### applySpecPatch Apply a single SpecStream patch to a Spec object (mutates in place, returns the spec): ```typescript import { applySpecPatch } from '@json-render/core'; let spec: Spec = { root: "", elements: {} }; applySpecPatch(spec, { op: "add", path: "/root", value: "main" }); // For React state updates, spread to create a new reference: setSpec({ ...applySpecPatch(spec, patch) }); ``` ### nestedToFlat Convert a nested element tree (with inline children) into the flat `Spec` format: ```typescript import { nestedToFlat } from '@json-render/core'; const flat = nestedToFlat({ type: "Card", props: { title: "Hello" }, children: [ { type: "Text", props: { content: "World" }, children: [] } ], }); // { root: "el-0", elements: { "el-0": ..., "el-1": ... } } ``` ### createJsonRenderTransform Low-level `TransformStream` that separates text from JSONL patches in a mixed AI stream. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text. The transform properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`. ```typescript import { createJsonRenderTransform } from '@json-render/core'; const transform = createJsonRenderTransform(); // Use with ReadableStream.pipeThrough(transform) for custom pipelines ``` Most users should use `pipeJsonRender()` instead, which wraps this transform for the common AI SDK use case. ### createMixedStreamParser Parse a mixed stream of text and JSONL patches (used for Inline mode): ```typescript import { createMixedStreamParser } from '@json-render/core'; const parser = createMixedStreamParser({ onText: (text) => appendToMessage(text), onPatch: (patch) => applySpecPatch(spec, patch), }); // As chunks arrive from the stream: for await (const chunk of stream) { parser.push(chunk); } parser.flush(); ``` ### pipeJsonRender Pipe an AI SDK `UIMessageStream` through the json-render transform. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text. Used in Inline mode API routes. ```typescript import { pipeJsonRender } from '@json-render/core'; import { createUIMessageStream, createUIMessageStreamResponse } from 'ai'; const stream = createUIMessageStream({ execute: async ({ writer }) => { writer.merge(pipeJsonRender(result.toUIMessageStream())); }, }); return createUIMessageStreamResponse({ stream }); ``` See [Generation Modes](/docs/generation-modes) for full Inline mode setup. ### SpecStream Types Fully compliant with [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902): ```typescript interface SpecStreamLine { op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'; path: string; value?: unknown; // Required for add, replace, test from?: string; // Required for move, copy } interface SpecStreamCompiler { push(chunk: string): { result: T; newPatches: SpecStreamLine[] }; getResult(): T; getPatches(): SpecStreamLine[]; reset(): void; } interface MixedStreamCallbacks { onText: (text: string) => void; onPatch: (patch: SpecStreamLine) => void; } interface MixedStreamParser { push(chunk: string): void; flush(): void; } ``` ## Utility Functions ### Path Utilities ```typescript import { getByPath, setByPath } from '@json-render/core'; // Get value by JSON Pointer path const value = getByPath(state, '/user/name'); // "Alice" // Set value by path (mutates object) setByPath(state, '/user/email', 'alice@example.com'); ``` ### resolveDynamicValue ```typescript import { resolveDynamicValue } from '@json-render/core'; // Resolve a dynamic value against state const name = resolveDynamicValue("Hello", state); // "Hello" const name2 = resolveDynamicValue({ $state: "/user/name" }, state); // "Alice" ``` ### findFormValue ```typescript import { findFormValue } from '@json-render/core'; // Find form values regardless of path format // Checks: params.name, params["form.name"], state["form.name"], state.form.name const value = findFormValue("name", params, state); ``` ## buildUserPrompt Build structured user prompts for AI generation, with support for refinement and state context. ```typescript import { buildUserPrompt } from '@json-render/core'; function buildUserPrompt(options: UserPromptOptions): string interface UserPromptOptions { prompt: string; // The user's text prompt currentSpec?: Spec | null; // Existing spec to refine (triggers edit mode) state?: Record | null; // Runtime state context to include maxPromptLength?: number; // Max length for user text (truncates before wrapping) editModes?: EditMode[]; // Edit modes for refinement (default: ["patch"]) } ``` ### Fresh generation ```typescript const userPrompt = buildUserPrompt({ prompt: "create a todo app" }); ``` ### Refinement (edit modes) When `currentSpec` is provided, the prompt instructs the AI to use the specified edit modes instead of recreating the entire spec. Available modes: `"patch"` (RFC 6902), `"merge"` (RFC 7396), and `"diff"` (unified diff). ```typescript const userPrompt = buildUserPrompt({ prompt: "add a dark mode toggle", currentSpec: existingSpec, editModes: ["patch", "merge"], }); ``` ### With state context Include runtime state so the AI knows what data is available: ```typescript const userPrompt = buildUserPrompt({ prompt: "show my data", state: { todos: [{ text: "Buy milk" }] }, }); ``` ## Edit Modes Universal edit mode utilities for modifying existing specs. Used by `buildUserPrompt` internally and available for direct use. ```typescript import { buildEditInstructions, buildEditUserPrompt, isNonEmptySpec, type EditMode, type EditConfig, } from '@json-render/core'; type EditMode = "patch" | "merge" | "diff"; ``` ### buildEditInstructions Generate the prompt section describing available edit modes. Supports both JSON and YAML formats. ```typescript function buildEditInstructions(config: EditConfig, format: "json" | "yaml"): string const instructions = buildEditInstructions({ modes: ["patch", "merge"] }, "json"); ``` ### buildEditUserPrompt Build a user prompt for editing an existing spec. Includes the current spec (with line numbers when diff mode is enabled) and mode-specific instructions. ```typescript function buildEditUserPrompt(options: BuildEditUserPromptOptions): string interface BuildEditUserPromptOptions { prompt: string; currentSpec?: Spec | null; config?: EditConfig; format: "json" | "yaml"; maxPromptLength?: number; serializer?: (spec: Spec) => string; } ``` ### isNonEmptySpec Check whether a value is a non-empty spec (has a root string and at least one element). ```typescript function isNonEmptySpec(spec: unknown): spec is Spec ``` ## Deep Merge and Diff Format-agnostic utilities for merging and diffing spec objects. ### deepMergeSpec Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse. Neither input is mutated. ```typescript import { deepMergeSpec } from '@json-render/core'; function deepMergeSpec( base: Record, patch: Record ): Record const merged = deepMergeSpec(currentSpec, { elements: { main: { props: { title: "New" } } } }); ``` ### diffToPatches Generate RFC 6902 JSON Patch operations that transform one object into another. Arrays are compared shallowly and replaced atomically; plain objects recurse. ```typescript import { diffToPatches } from '@json-render/core'; function diffToPatches( oldObj: Record, newObj: Record, basePath?: string ): JsonPatch[] const patches = diffToPatches(oldSpec, newSpec); // [{ op: "replace", path: "/elements/main/props/title", value: "New Title" }] ``` ## evaluateVisibility Evaluates a visibility condition against the state model. ```typescript function evaluateVisibility( condition: VisibilityCondition | undefined, ctx: VisibilityContext ): boolean interface VisibilityContext { stateModel: StateModel; repeatItem?: unknown; // Current repeat item (inside repeat scope) repeatIndex?: number; // Current repeat array index (inside repeat scope) } type VisibilityCondition = | { $state: string } // truthiness | { $state: string; not: true } // falsy | { $state: string; eq: unknown } // equality | { $state: string; neq: unknown } // inequality | { $state: string; gt: number } // greater than | { $state: string; gte: number } // gte | { $state: string; lt: number } // lt | { $state: string; lte: number } // lte | { $item: string } // item field (repeat scope) | { $item: string; eq: unknown } // item field equality | { $index: true } // index truthiness (repeat scope) | { $index: true; gt: number } // index comparison | VisibilityCondition[] // implicit AND | { $and: VisibilityCondition[] } // explicit AND | { $or: VisibilityCondition[] } // OR | boolean; // always / never ``` ## Types ### UIElement ```typescript interface UIElement { type: string; props: Record; children?: string[]; // Keys of child elements visible?: VisibilityCondition; on?: Record; // Event bindings repeat?: { statePath: string; key?: string }; // Repeat for arrays } ``` Elements are stored in the `elements` map keyed by string IDs. The key comes from the map, not from the element itself. ### Spec (Element Tree) ```typescript interface Spec { root: string | null; // Key of root element elements: Record; // Flat element map state?: Record; // Initial state model } ``` Elements are stored as a flat map with string keys. The tree structure is built by following the `children` arrays. ### ActionBinding ```typescript interface ActionBinding { action: string; params?: Record; confirm?: { title: string; message: string; variant?: 'default' | 'danger'; }; onSuccess?: { set: Record }; onError?: { set: Record }; preventDefault?: boolean; // Prevent default browser behavior (e.g. navigation on links) } ``` ### ValidationSchema ```typescript interface ValidationSchema { checks: ValidationCheck[]; validateOn?: 'change' | 'blur' | 'submit'; } interface ValidationCheck { type: string; args?: Record; message: string; } ``` ================================================ FILE: apps/web/app/(main)/docs/api/image/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/image") # @json-render/image Image renderer. Turn JSON specs into SVG and PNG images using [Satori](https://github.com/vercel/satori). ## Install ```bash npm install @json-render/core @json-render/image ``` For PNG output, also install the optional peer dependency: ```bash npm install @resvg/resvg-js ``` See the [Image example](https://github.com/vercel-labs/json-render/tree/main/examples/image) for a full working example. ## schema The image element schema for image specs. Use with `defineCatalog` from core. ```typescript import { defineCatalog } from '@json-render/core'; import { schema, standardComponentDefinitions } from '@json-render/image'; const catalog = defineCatalog(schema, { components: standardComponentDefinitions, }); ``` ## Render Functions Server-side functions for producing image output. Both accept a spec and optional `RenderOptions`. ```typescript import { renderToSvg, renderToPng } from '@json-render/image/render'; const svg = await renderToSvg(spec, { fonts }); const png = await renderToPng(spec, { fonts }); await writeFile('output.png', png); ``` ### RenderOptions ```typescript interface RenderOptions { registry?: ComponentRegistry; includeStandard?: boolean; // default: true state?: Record; fonts?: SatoriOptions['fonts']; width?: number; height?: number; } ```
Option Type Default Description
fonts {"SatoriOptions['fonts']"} [] Font data for text rendering (required for meaningful output)
width number Frame prop Override the output image width
height number Frame prop Override the output image height
registry {"Record"} {"{}"} Custom component map (merged with standard components)
includeStandard boolean true Include built-in standard components
state {"Record"} {"{}"} Initial state for $state / $cond dynamic prop resolution
## Standard Components ### Root #### Frame Root image container. Defines the output image dimensions and background. Must be the root element. ```typescript { width: number; height: number; backgroundColor: string | null; padding: number | null; display: "flex" | "none" | null; flexDirection: "row" | "column" | null; alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null; justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null; } ``` ### Layout #### Box Generic container with padding, margin, background, border, and flex alignment. Supports absolute positioning. ```typescript { padding: number | null; paddingTop: number | null; paddingBottom: number | null; paddingLeft: number | null; paddingRight: number | null; margin: number | null; backgroundColor: string | null; borderWidth: number | null; borderColor: string | null; borderRadius: number | null; flex: number | null; width: number | string | null; height: number | string | null; alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null; justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null; flexDirection: "row" | "column" | null; position: "relative" | "absolute" | null; top: number | null; left: number | null; right: number | null; bottom: number | null; overflow: "visible" | "hidden" | null; } ``` #### Row Horizontal flex layout with optional wrapping. ```typescript { gap: number | null; alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null; justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null; padding: number | null; flex: number | null; wrap: boolean | null; } ``` #### Column Vertical flex layout. ```typescript { gap: number | null; alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null; justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null; padding: number | null; flex: number | null; } ``` ### Content #### Heading Heading text at various levels. h1 is largest, h4 is smallest. ```typescript { text: string; level: "h1" | "h2" | "h3" | "h4" | null; color: string | null; align: "left" | "center" | "right" | null; letterSpacing: number | string | null; lineHeight: number | null; } ``` #### Text Body text with configurable size, color, weight, and alignment. ```typescript { text: string; fontSize: number | null; color: string | null; align: "left" | "center" | "right" | null; fontWeight: "normal" | "bold" | null; fontStyle: "normal" | "italic" | null; lineHeight: number | null; letterSpacing: number | string | null; textDecoration: "none" | "underline" | "line-through" | null; } ``` #### Image Image from a URL with optional dimensions and fit. ```typescript { src: string; width: number | null; height: number | null; borderRadius: number | null; objectFit: "contain" | "cover" | "fill" | "none" | null; } ``` ### Decorative #### Divider Horizontal line separator. ```typescript { color: string | null; thickness: number | null; marginTop: number | null; marginBottom: number | null; } ``` #### Spacer Empty vertical space. ```typescript { height: number | null; } ``` ## Catalog Definitions Pre-built definitions for creating image catalogs: ```typescript import { standardComponentDefinitions } from '@json-render/image/catalog'; import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/image'; const catalog = defineCatalog(schema, { components: { ...standardComponentDefinitions, // Add custom components }, }); ``` ## Server-Safe Import Import schema and catalog definitions without pulling in React or Satori: ```typescript import { schema, standardComponentDefinitions } from '@json-render/image/server'; ``` ## Sub-path Exports
Export Description
@json-render/image Full package: schema, renderer, components, render functions
@json-render/image/server Schema and catalog definitions only (no React or Satori)
@json-render/image/catalog Standard component definitions and types
@json-render/image/render Server-side render functions only
## Types
Export Description
ImageSchema Schema type for image specs
ImageSpec Spec type for image output
RenderOptions Options for render functions
ComponentRenderProps Props passed to component render functions
ComponentRenderer Component render function type
ComponentRegistry Map of component names to render functions
StandardComponentDefinitions Type of the standard component definitions object
StandardComponentProps{''} Inferred props type for a standard component by name
================================================ FILE: apps/web/app/(main)/docs/api/jotai/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/jotai") # @json-render/jotai Jotai adapter for json-render's `StateStore` interface. ## Installation ```bash npm install @json-render/jotai @json-render/core @json-render/react jotai ``` ## jotaiStateStore Create a `StateStore` backed by a Jotai atom. ```typescript import { jotaiStateStore } from "@json-render/jotai"; ``` ### Options
Option Type Required Description
atom {'WritableAtom'} Yes A writable atom holding the state model.
store Jotai Store No The Jotai store instance. Defaults to a new store created internally. Pass your own to share state with {''}.
### Example ```typescript import { atom } from "jotai"; import { jotaiStateStore } from "@json-render/jotai"; import { StateProvider } from "@json-render/react"; const uiAtom = atom>({ count: 0 }); const store = jotaiStateStore({ atom: uiAtom }); ``` ```tsx {/* json-render reads/writes go through Jotai */} ``` ### Shared Jotai Store If your app already uses a Jotai `` with a custom store, pass it so both json-render and your components share the same state: ```typescript import { atom, createStore } from "jotai"; import { Provider as JotaiProvider } from "jotai/react"; import { jotaiStateStore } from "@json-render/jotai"; import { StateProvider } from "@json-render/react"; const jStore = createStore(); const uiAtom = atom>({ count: 0 }); const store = jotaiStateStore({ atom: uiAtom, store: jStore }); ``` ```tsx {/* Both json-render and useAtom() see the same state */} ``` ## Re-exports
Export Source
StateStore @json-render/core
================================================ FILE: apps/web/app/(main)/docs/api/mcp/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/mcp") # @json-render/mcp MCP Apps integration for json-render. Serve json-render UIs as interactive [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients. ## Install ```bash npm install @json-render/mcp @json-render/core @modelcontextprotocol/sdk ``` For the iframe-side React UI, also install: ```bash npm install @json-render/react react react-dom ``` See the [MCP example](https://github.com/vercel-labs/json-render/tree/main/examples/mcp) for a full working example. ## Overview MCP Apps let MCP servers return interactive HTML UIs that render directly inside chat conversations. `@json-render/mcp` bridges json-render catalogs with the MCP Apps protocol: 1. Your **catalog** defines which components and actions the AI can use 2. The **MCP server** exposes the catalog as a tool with the spec schema 3. The **bundled HTML** renders json-render specs inside the host's sandboxed iframe 4. The AI generates a spec, the host renders it, and users interact with the live UI ## Server API ### createMcpApp Create a fully-configured MCP server. This is the main entry point. ```typescript import { createMcpApp } from "@json-render/mcp"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import fs from "node:fs"; const server = createMcpApp({ name: "My Dashboard", version: "1.0.0", catalog: myCatalog, html: fs.readFileSync("dist/index.html", "utf-8"), }); await server.connect(new StdioServerTransport()); ``` #### CreateMcpAppOptions
Option Type Description
name string Server name shown in client UIs
version string Server version
catalog Catalog json-render catalog defining available components
html string Self-contained HTML for the iframe UI
tool McpToolOptions Optional tool name/title/description overrides
### registerJsonRenderTool Register a json-render tool on an existing `McpServer`. Use this when you need to add json-render to a server that has other tools. ```typescript import { registerJsonRenderTool } from "@json-render/mcp"; registerJsonRenderTool(server, { catalog, name: "render-ui", title: "Render UI", description: "Render an interactive UI", resourceUri: "ui://render-ui/view.html", }); ``` ### registerJsonRenderResource Register the UI resource that serves the bundled HTML. ```typescript import { registerJsonRenderResource } from "@json-render/mcp"; registerJsonRenderResource(server, { resourceUri: "ui://render-ui/view.html", html: bundledHtml, }); ``` ## Client API (`@json-render/mcp/app`) These exports run inside the sandboxed iframe rendered by the MCP host. ### useJsonRenderApp React hook that connects to the MCP host, listens for tool results, and maintains the current json-render spec. ```tsx import { useJsonRenderApp } from "@json-render/mcp/app"; import { JSONUIProvider, Renderer } from "@json-render/react"; function McpAppView({ registry }) { const { spec, loading, connected, error } = useJsonRenderApp({ name: "my-app", version: "1.0.0", }); if (error) return
Error: {error.message}
; if (!spec) return
Waiting...
; return ( ); } ``` #### UseJsonRenderAppReturn
Field Type Description
spec {'Spec | null'} Current json-render spec
loading boolean Whether the spec is still being received
connected boolean Whether connected to the host
connecting boolean Whether currently connecting
error {'Error | null'} Connection error, if any
app {'App | null'} The underlying MCP App instance
callServerTool {'(name, args?) => Promise'} Call an MCP server tool and update spec from result
### buildAppHtml Generate a self-contained HTML page from bundled JavaScript and CSS. ```typescript import { buildAppHtml } from "@json-render/mcp/app"; import fs from "node:fs"; const html = buildAppHtml({ title: "Dashboard", js: fs.readFileSync("dist/app.js", "utf-8"), css: fs.readFileSync("dist/app.css", "utf-8"), }); ``` ## Client Configuration ### Cursor Add to `.cursor/mcp.json`: ```json { "mcpServers": { "json-render": { "command": "npx", "args": ["tsx", "path/to/server.ts", "--stdio"] } } } ``` ### Claude Desktop Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json { "mcpServers": { "json-render": { "command": "npx", "args": ["tsx", "/absolute/path/to/server.ts", "--stdio"] } } } ``` ## Supported Clients MCP Apps are supported by Claude (web and desktop), ChatGPT, VS Code (GitHub Copilot), Cursor, Goose, and Postman. ================================================ FILE: apps/web/app/(main)/docs/api/react/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/react") # @json-render/react React components, providers, and hooks. ## Providers ### StateProvider ```tsx {children} ```
Prop Type Description
store StateStore External store (controlled mode). When provided, initialState and onStateChange are ignored.
initialState Record<string, unknown> Initial state model (uncontrolled mode).
onStateChange {'(changes: Array<{ path: string; value: unknown }>) => void'} Callback when state changes (uncontrolled mode). Called once per set or update with all changed entries.
#### External Store (Controlled Mode) Pass a `StateStore` to bypass the internal state and wire json-render to any state management library: ```tsx import { createStateStore, type StateStore } from "@json-render/react"; const store = createStateStore({ count: 0 }); {children} // Mutate from anywhere — React re-renders automatically: store.set("/count", 1); ``` The `store` prop is also available on `JSONUIProvider` and `createRenderer`. ### ActionProvider ```tsx }> {children} type ActionHandler = (params: Record) => void | Promise; ``` ### VisibilityProvider ```tsx {children} ``` `VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax. ### ValidationProvider ```tsx }> {children} type ValidationFunction = (value: unknown, args?: object) => boolean | Promise; ``` ## defineRegistry Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types. When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. ```tsx import { defineRegistry } from '@json-render/react'; const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) =>
{props.title}{children}
, Button: ({ props, emit }) => ( ), }, }); // Pass to ``` ## Components ### Renderer ```tsx type Registry = Record>; ``` ### JSONUIProvider Convenience wrapper that combines `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`. Accepts all their props plus:
Prop Type Description
functions Record<string, ComputedFunction> Named functions for $computed expressions in props
```tsx { /* ... */ } }} functions={{ fullName: (args) => `${args.first} ${args.last}` }} > ``` The `functions` prop is also available on `createRenderer`. ### Component Props (via defineRegistry) ```tsx interface ComponentContext

{ props: P; // Typed props from catalog children?: React.ReactNode; // Rendered children (for slot components) emit: (event: string) => void; // Emit a named event (always defined) on: (event: string) => EventHandle; // Get event handle with metadata loading?: boolean; bindings?: Record; // State paths from $bindState/$bindItem expressions } interface EventHandle { emit: () => void; // Fire the event shouldPreventDefault: boolean; // Whether any binding requested preventDefault bound: boolean; // Whether any handler is bound } ``` Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault`: ```tsx Link: ({ props, on }) => { const click = on("click"); return ( { if (click.shouldPreventDefault) e.preventDefault(); click.emit(); }} > {props.label} ); }, ``` ### BaseComponentProps Catalog-agnostic base type for building reusable component libraries (e.g. `@json-render/shadcn`) that are not tied to a specific catalog: ```typescript import type { BaseComponentProps } from "@json-render/react"; const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (

{props.title}{children}
); ``` ## Hooks ### useUIStream ```typescript const { spec, // Spec | null - current UI state isStreaming, // boolean - true while streaming error, // Error | null send, // (prompt: string, context?: Record) => Promise clear, // () => void - reset spec and error } = useUIStream({ api: string, // API endpoint URL onComplete?: (spec: Spec) => void, // Called when streaming completes onError?: (error: Error) => void, // Called when an error occurs }); ``` ### useStateStore ```typescript const { state, // StateModel (Record) get, // (path: string) => unknown set, // (path: string, value: unknown) => void update, // (updates: Record) => void } = useStateStore(); ``` ### useStateValue ```typescript const value = useStateValue(path: string); ``` ### useStateBinding (deprecated) > **Deprecated.** Use `useBoundProp` with `$bindState` expressions instead. ```typescript const [value, setValue] = useStateBinding(path: string); ``` ### useActions ```typescript const { execute } = useActions(); // execute(binding: ActionBinding) => Promise ``` ### useAction ```typescript const { execute, isLoading } = useAction(binding: ActionBinding); // execute() => Promise ``` ### useIsVisible ```typescript const isVisible = useIsVisible(condition?: VisibilityCondition); ``` ### useFieldValidation ```typescript const { state, // FieldValidationState validate, // () => ValidationResult touch, // () => void clear, // () => void errors, // string[] isValid, // boolean } = useFieldValidation(path: string, config?: ValidationConfig); ``` `ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`. ### useOptionalValidation Non-throwing variant of `useValidation()`. Returns `null` when no `ValidationProvider` is present, instead of throwing. Useful in components that may or may not be rendered inside a validation context. ```typescript const validation = useOptionalValidation(); // ValidationContextValue | null ``` ### useBoundProp Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path. ```typescript const [value, setValue] = useBoundProp( propValue: T | undefined, // The already-resolved prop value bindingPath: string | undefined // From bindings?.value ); ``` Use inside registry components: ```tsx const Input: ComponentRenderer = ({ props, bindings }) => { const [value, setValue] = useBoundProp(props.value, bindings?.value); return setValue(e.target.value)} />; }; ``` ### Chat Hooks Two hooks are available for chat + GenUI, depending on your setup: - **`useChatUI`** -- Self-contained chat hook with its own message state, fetch logic, and mixed stream parsing. Use when you want a standalone chat experience without the Vercel AI SDK. - **`useJsonRenderMessage`** -- Extracts spec + text from an AI SDK `UIMessage.parts` array. Use with the Vercel AI SDK's `useChat` for full AI SDK integration. ### useChatUI Hook for chat + GenUI experiences. Manages a multi-turn conversation where each assistant message can contain both text and a json-render UI spec. ```typescript const { messages, // ChatMessage[] - all messages in the conversation isStreaming, // boolean - true while streaming error, // Error | null send, // (text: string) => Promise clear, // () => void - reset conversation } = useChatUI({ api: string, // API endpoint onComplete?: (message: ChatMessage) => void, // Called when streaming completes onError?: (error: Error) => void, // Called on error }); interface ChatMessage { id: string; role: "user" | "assistant"; text: string; spec: Spec | null; } ``` ### useJsonRenderMessage Extract a spec and text content from an AI SDK message's `parts` array. Designed for integration with Vercel AI SDK's `useChat`. ```typescript const { spec, text, hasSpec } = useJsonRenderMessage(parts: DataPart[]); // spec: Spec | null - compiled from JSONL patches in data parts // text: string - concatenated text parts // hasSpec: boolean - true when spec is non-null ``` ### buildSpecFromParts / getTextFromParts Standalone utilities for extracting spec and text from AI SDK message parts (non-hook versions): ```typescript import { buildSpecFromParts, getTextFromParts } from '@json-render/react'; const spec = buildSpecFromParts(message.parts); // Spec | null const text = getTextFromParts(message.parts); // string ``` ================================================ FILE: apps/web/app/(main)/docs/api/react-email/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/react-email") # @json-render/react-email React Email renderer. Turn JSON specs into HTML or plain-text emails using `@react-email/components` and `@react-email/render`. ## Install ```bash npm install @json-render/core @json-render/react-email @react-email/components @react-email/render ``` See the [React Email example](https://github.com/vercel-labs/json-render/tree/main/examples/react-email) for a full working example. ## schema The email element schema for specs. Use with `defineCatalog` from core. ```typescript import { defineCatalog } from '@json-render/core'; import { schema, standardComponentDefinitions } from '@json-render/react-email'; const catalog = defineCatalog(schema, { components: standardComponentDefinitions, }); ``` ## Render Functions Server-side functions for producing email output. All accept a spec and optional `RenderOptions`. ```typescript import { renderToHtml, renderToPlainText } from '@json-render/react-email'; const html = await renderToHtml(spec); const plainText = await renderToPlainText(spec); ``` ### RenderOptions ```typescript interface RenderOptions { registry?: ComponentRegistry; includeStandard?: boolean; // default: true state?: Record; } ```
Option Description
registry Custom component map (merged with standard components)
includeStandard Include built-in standard components (default: true)
state Initial state for $state / $cond dynamic prop resolution
## defineRegistry Create a type-safe component registry from a catalog. Components receive `{ props, children, emit, bindings, loading }`. ```tsx import { defineRegistry } from '@json-render/react-email'; import { Container, Heading, Text } from '@react-email/components'; const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => ( {props.title} {children} ), }, }); const html = await renderToHtml(spec, { registry }); ``` ## createRenderer Create a standalone renderer component wired to state, actions, and validation (for interactive previews in the browser). ```typescript import { createRenderer } from '@json-render/react-email'; const EmailRenderer = createRenderer(catalog, components); ``` ## Renderer The main component that renders a spec to React Email elements. Use inside `JSONUIProvider` when you need state, actions, or visibility. ```typescript interface RendererProps { spec: Spec | null; registry?: ComponentRegistry; includeStandard?: boolean; // default: true loading?: boolean; fallback?: ComponentRenderer; } ``` ## Standard Components ### Document structure
Component Description
Html Top-level email wrapper. Must be the root element.
Head Email head section. Place inside Html.
Body Email body wrapper. Place inside Html.
### Layout
Component Description
Container Constrains content width (e.g. max-width 600px).
Section Groups related content.
Row Horizontal layout row.
Column Column within a Row.
### Content
Component Description
Heading Heading text (h1-h6).
Text Body text paragraph.
Link Hyperlink with text and href.
Button Call-to-action button (link styled as button).
Image Image from URL.
Hr Horizontal rule separator.
### Utility
Component Description
Preview Preview text for inbox (inside Html).
Markdown Renders markdown content as email-safe HTML.
## Server-Safe Import Import schema and catalog definitions without pulling in React or `@react-email/components`: ```typescript import { schema, standardComponentDefinitions } from '@json-render/react-email/server'; ``` ## Sub-path Exports
Export Description
@json-render/react-email Full package: schema, renderer, components, render functions
@json-render/react-email/server Schema and catalog definitions only (no React)
@json-render/react-email/catalog Standard component definitions and types
@json-render/react-email/render Server-side render functions only
## Types
Export Description
ReactEmailSchema Schema type for email specs
ReactEmailSpec Spec type for email documents
RenderOptions Options for render functions
ComponentContext Typed component render function context
ComponentFn Component render function type
StandardComponentDefinitions Type of the standard component definitions object
StandardComponentProps<K> Inferred props type for a standard component by name
================================================ FILE: apps/web/app/(main)/docs/api/react-native/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/react-native") # @json-render/react-native React Native renderer with standard components, providers, and hooks. ## Standard Components ### Layout
ComponentPropsDescription
Containerpadding, background, borderRadius, borderColor, flexBasic wrapper with styling
Rowgap, align, justify, flex, wrapHorizontal flex layout
Columngap, align, justify, flexVertical flex layout
ScrollContainerdirectionScrollable area (vertical or horizontal)
SafeAreaedgesSafe area insets for notch/home indicator
Pressableaction, actionParamsTouchable wrapper that triggers actions
Spacersize, flexFixed or flexible spacing
Dividercolor, thicknessThin line separator
### Content
ComponentPropsDescription
Headingtext, level, align, colorHeading text (levels 1-6)
Paragraphtext, align, colorBody text
Labeltext, color, boldSmall label text
Imageuri, width, height, resizeMode, borderRadiusImage display
Avataruri, size, fallbackCircular avatar
Badgelabel, color, textColorStatus badge
Chiplabel, selected, colorTag/chip
### Input
ComponentPropsDescription
Buttonlabel, variant, size, disabled, action, actionParamsPressable button
TextInputplaceholder, value (use $bindState), secure, keyboardType, multilineText input field
Switchchecked (use $bindState), labelToggle switch
Checkboxchecked (use $bindState), labelCheckbox with label
Slidervalue (use $bindState), min, max, stepRange slider
SearchBarplaceholder, value (use $bindState)Search input
### Feedback
ComponentPropsDescription
Spinnersize, colorLoading indicator
ProgressBarprogress, color, trackColorProgress indicator
### Composite
ComponentPropsDescription
Cardtitle, subtitle, paddingCard container
ListItemtitle, subtitle, leading, trailing, action, actionParamsList row
Modalvisible, titleBottom sheet modal
## Providers ### StateProvider ```tsx {children} ```
PropTypeDescription
storeStateStoreExternal store (controlled mode). When provided, initialState and onStateChange are ignored.
initialStateRecord<string, unknown>Initial state model (uncontrolled mode).
onStateChange{'(changes: Array<{ path: string; value: unknown }>) => void'}Callback when state changes (uncontrolled mode). Called once per set or update with all changed entries.
#### External Store (Controlled Mode) Pass a `StateStore` to bypass the internal state and wire json-render to any state management library: ```tsx import { createStateStore, type StateStore } from "@json-render/react-native"; const store = createStateStore({ count: 0 }); {children} // Mutate from anywhere — components re-render automatically: store.set("/count", 1); ``` The `store` prop is also available on `JSONUIProvider` and `createRenderer`. ### ActionProvider ```tsx }> {children} ``` ### VisibilityProvider ```tsx {children} ``` Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax. ### ValidationProvider ```tsx {children} ``` ## defineRegistry Create a type-safe component registry. Standard components are built-in; only register custom components. ```tsx import { defineRegistry, type Components } from '@json-render/react-native'; const { registry } = defineRegistry(catalog, { components: { Icon: ({ props }) => , } as Components, }); ``` ## Hooks ### useUIStream ```typescript const { spec, // Spec | null - current UI state isStreaming, // boolean - true while streaming error, // Error | null send, // (prompt: string) => Promise clear, // () => void - reset spec and error } = useUIStream({ api: string, onComplete?: (spec: Spec) => void, onError?: (error: Error) => void, }); ``` ### useStateStore ```typescript const { state, get, set, update } = useStateStore(); ``` ### useStateValue ```typescript const value = useStateValue(path: string); ``` ### useStateBinding (deprecated) > **Deprecated.** Use `useBoundProp` with `$bindState` expressions instead. ```typescript const [value, setValue] = useStateBinding(path: string); ``` ### useActions ```typescript const { execute } = useActions(); ``` ### useIsVisible ```typescript const isVisible = useIsVisible(condition?: VisibilityCondition); ``` ## Catalog Exports ```typescript import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/react-native/catalog"; import { schema } from "@json-render/react-native/schema"; ```
ExportPurpose
standardComponentDefinitionsCatalog definitions for all 25+ standard components
standardActionDefinitionsCatalog definitions for standard actions (setState, navigate)
schemaReact Native element tree schema
================================================ FILE: apps/web/app/(main)/docs/api/react-pdf/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/react-pdf") # @json-render/react-pdf PDF document renderer. Turn JSON specs into PDFs using `@react-pdf/renderer`. ## Install ```bash npm install @json-render/core @json-render/react-pdf ``` See the [React PDF example](https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf) for a full working example. ## schema The PDF element schema for document specs. Use with `defineCatalog` from core. ```typescript import { defineCatalog } from '@json-render/core'; import { schema, standardComponentDefinitions } from '@json-render/react-pdf'; const catalog = defineCatalog(schema, { components: standardComponentDefinitions, }); ``` ## Render Functions Server-side functions for producing PDF output. All accept a spec and optional `RenderOptions`. ```typescript import { renderToBuffer, renderToStream, renderToFile } from '@json-render/react-pdf'; const buffer = await renderToBuffer(spec); const stream = await renderToStream(spec); stream.pipe(res); await renderToFile(spec, './output.pdf'); ``` ### RenderOptions ```typescript interface RenderOptions { registry?: ComponentRegistry; includeStandard?: boolean; // default: true state?: Record; } ```
Option Description
registry Custom component map (merged with standard components)
includeStandard Include built-in standard components (default: true)
state Initial state for $state / $cond dynamic prop resolution
## defineRegistry Create a type-safe component registry from a catalog. Components receive `{ props, children, emit, bindings, loading }`. ```tsx import { defineRegistry } from '@json-render/react-pdf'; import { View, Text } from '@react-pdf/renderer'; const { registry } = defineRegistry(catalog, { components: { Badge: ({ props }) => ( {props.label} ), }, }); const buffer = await renderToBuffer(spec, { registry }); ``` ## createRenderer Create a standalone renderer component wired to state, actions, and validation. ```typescript import { createRenderer } from '@json-render/react-pdf'; const PDFRenderer = createRenderer(catalog, components); ``` ```typescript interface CreateRendererProps { spec: Spec | null; store?: StateStore; state?: Record; onAction?: (actionName: string, params?: Record) => void; onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; loading?: boolean; fallback?: ComponentRenderer; } ``` When `store` is provided, `state` and `onStateChange` are ignored (controlled mode). ## Renderer The main component that renders a spec to `@react-pdf/renderer` elements. ```typescript interface RendererProps { spec: Spec | null; registry?: ComponentRegistry; includeStandard?: boolean; // default: true loading?: boolean; fallback?: ComponentRenderer; } ``` ## Standard Components ### Document Structure #### Document Top-level PDF wrapper. Must be the root element. Children must be `Page` components. ```typescript { title: string | null; author: string | null; subject: string | null; } ``` #### Page A page in the document with configurable size, orientation, and margins. ```typescript { size: "A4" | "A3" | "A5" | "LETTER" | "LEGAL" | "TABLOID" | null; orientation: "portrait" | "landscape" | null; marginTop: number | null; marginBottom: number | null; marginLeft: number | null; marginRight: number | null; backgroundColor: string | null; } ``` ### Layout #### View Generic container with padding, margin, background, border, and flex alignment. ```typescript { padding: number | null; paddingTop: number | null; paddingBottom: number | null; paddingLeft: number | null; paddingRight: number | null; margin: number | null; backgroundColor: string | null; borderWidth: number | null; borderColor: string | null; borderRadius: number | null; flex: number | null; alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null; justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null; } ``` #### Row Horizontal flex layout with optional wrapping. ```typescript { gap: number | null; alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null; justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null; padding: number | null; flex: number | null; wrap: boolean | null; } ``` #### Column Vertical flex layout. ```typescript { gap: number | null; alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null; justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null; padding: number | null; flex: number | null; } ``` ### Content #### Heading h1-h4 heading text with configurable color and alignment. ```typescript { text: string; level: "h1" | "h2" | "h3" | "h4" | null; color: string | null; align: "left" | "center" | "right" | null; } ``` #### Text Body text with full styling control. ```typescript { text: string; fontSize: number | null; color: string | null; align: "left" | "center" | "right" | null; fontWeight: "normal" | "bold" | null; fontStyle: "normal" | "italic" | null; lineHeight: number | null; } ``` #### Image Image from a URL with optional dimensions and fit. ```typescript { src: string; width: number | null; height: number | null; objectFit: "contain" | "cover" | "fill" | "none" | null; } ``` #### Link Hyperlink with visible text. ```typescript { text: string; href: string; fontSize: number | null; color: string | null; } ``` ### Data #### Table Data table with typed columns and string rows. Supports header styling and striped rows. ```typescript { columns: { header: string; width?: string; align?: "left" | "center" | "right" }[]; rows: string[][]; headerBackgroundColor: string | null; headerTextColor: string | null; borderColor: string | null; fontSize: number | null; striped: boolean | null; } ``` #### List Ordered or unordered list. ```typescript { items: string[]; ordered: boolean | null; fontSize: number | null; color: string | null; spacing: number | null; } ``` ### Decorative #### Divider Horizontal line separator. ```typescript { color: string | null; thickness: number | null; marginTop: number | null; marginBottom: number | null; } ``` #### Spacer Empty vertical space. ```typescript { height: number | null; } ``` ### Page-Level #### PageNumber Renders current page number and total pages. Format uses `{pageNumber}` and `{totalPages}` placeholders. ```typescript { format: string | null; // default: "{pageNumber} / {totalPages}" fontSize: number | null; color: string | null; align: "left" | "center" | "right" | null; } ``` ## External Store (Controlled Mode) Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer` for full control over state: ```tsx import { createStateStore, type StateStore } from "@json-render/react-pdf"; const store = createStateStore({ invoice: { total: 100 } }); store.set("/invoice/total", 200); ``` When `store` is provided, `initialState` / `state` and `onStateChange` are ignored. ## Server-Safe Import Import schema and catalog definitions without pulling in React or `@react-pdf/renderer`: ```typescript import { schema, standardComponentDefinitions } from '@json-render/react-pdf/server'; ``` ## Sub-path Exports
Export Description
@json-render/react-pdf Full package: schema, renderer, components, render functions
@json-render/react-pdf/server Schema and catalog definitions only (no React)
@json-render/react-pdf/catalog Standard component definitions and types
@json-render/react-pdf/render Server-side render functions only
## Types
Export Description
ReactPdfSchema Schema type for PDF specs
ReactPdfSpec Spec type for PDF documents
RenderOptions Options for render functions
ComponentContext Typed component render function context
ComponentFn Component render function type
StandardComponentDefinitions Type of the standard component definitions object
StandardComponentProps<K> Inferred props type for a standard component by name
================================================ FILE: apps/web/app/(main)/docs/api/react-three-fiber/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/react-three-fiber") # @json-render/react-three-fiber React Three Fiber renderer for json-render. 19 built-in 3D components for meshes, lights, models, environments, text, cameras, and controls. ## Installation ```bash npm install @json-render/react-three-fiber @json-render/core @json-render/react @react-three/fiber @react-three/drei three zod ``` ## Entry Points
Entry Point Exports Use For
@json-render/react-three-fiber threeComponents, ThreeRenderer, ThreeCanvas, schemas React Three Fiber implementations and renderer
@json-render/react-three-fiber/catalog threeComponentDefinitions Catalog schemas (no R3F dependency, safe for server)
## Usage ```tsx import {'{ defineCatalog }'} from "@json-render/core"; import {'{ schema, defineRegistry }'} from "@json-render/react"; import {'{'} threeComponentDefinitions, threeComponents, ThreeCanvas, {'}'} from "@json-render/react-three-fiber"; const catalog = defineCatalog(schema, {'{'} components: {'{'} Box: threeComponentDefinitions.Box, Sphere: threeComponentDefinitions.Sphere, AmbientLight: threeComponentDefinitions.AmbientLight, DirectionalLight: threeComponentDefinitions.DirectionalLight, OrbitControls: threeComponentDefinitions.OrbitControls, {'}'}, actions: {'{}'}, {'}'}); const {'{ registry }'} = defineRegistry(catalog, {'{'} components: {'{'} Box: threeComponents.Box, Sphere: threeComponents.Sphere, AmbientLight: threeComponents.AmbientLight, DirectionalLight: threeComponents.DirectionalLight, OrbitControls: threeComponents.OrbitControls, {'}'}, {'}'}); ``` ### ThreeCanvas (convenience) ```tsx ``` ### Manual Canvas Setup ```tsx import {'{ Canvas }'} from "@react-three/fiber"; import {'{ ThreeRenderer }'} from "@json-render/react-three-fiber"; ``` ## Components ### Primitives
Component Description Key Props
Box Box mesh (default 1x1x1) width, height, depth, material
Sphere Sphere mesh radius, widthSegments, heightSegments, material
Cylinder Cylinder mesh radiusTop, radiusBottom, height, material
Cone Cone mesh radius, height, material
Torus Torus (donut) mesh radius, tube, material
Plane Flat plane mesh width, height, material
Capsule Capsule mesh radius, length, material
All primitives share: position, rotation, scale, castShadow, receiveShadow, material. ### Material Schema
Property Type Default
color string "#ffffff"
metalness number 0
roughness number 1
emissive string "#000000"
emissiveIntensity number 1
opacity number 1
transparent boolean false
wireframe boolean false
### Lights
Component Description Key Props
AmbientLight Uniform illumination color, intensity
DirectionalLight Sunlight-style position, color, intensity, castShadow
PointLight Radiates from a point position, color, intensity, distance, decay
SpotLight Cone of light position, color, intensity, angle, penumbra
### Other Components
Component Description Key Props
Group Container for children position, rotation, scale
Model GLTF/GLB model loader url, position, rotation, scale
Environment HDRI environment map preset, background, blur, intensity
Fog Linear fog effect color, near, far
GridHelper Reference grid size, divisions, color
Text3D 3D text (SDF) text, fontSize, color, anchorX, anchorY
PerspectiveCamera Camera position, fov, near, far, makeDefault
OrbitControls Camera controls enableDamping, enableZoom, autoRotate
## Shared Schemas Reusable Zod schemas for custom 3D components: ```tsx import {'{ vector3Schema, materialSchema, transformProps, shadowProps }'} from "@json-render/react-three-fiber"; ```
Export Description
vector3Schema z.tuple([z.number(), z.number(), z.number()])
materialSchema Standard material props (color, metalness, roughness, etc.)
transformProps {'{ position, rotation, scale }'} schema fields
shadowProps {'{ castShadow, receiveShadow }'} schema fields
## ThreeRenderer
Prop Type Description
spec Spec | null The spec to render as a 3D scene
registry ComponentRegistry Component registry from defineRegistry
store StateStore External state store (controlled mode)
initialState Record<string, unknown> Initial state (uncontrolled mode)
handlers Record<string, Function> Action handlers
loading boolean Whether the spec is streaming
children ReactNode Additional R3F elements alongside the spec
## ThreeCanvas Extends ThreeRendererProps with Canvas options:
Prop Type Description
shadows boolean Enable shadow maps
camera object Default camera config (position, fov, etc.)
className string CSS class for the canvas container
style CSSProperties Inline styles for the canvas container
## Type Helpers ```tsx import type {'{ ThreeProps }'} from "@json-render/react-three-fiber"; type BoxProps = ThreeProps<"Box">; type SphereProps = ThreeProps<"Sphere">; ``` ================================================ FILE: apps/web/app/(main)/docs/api/redux/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/redux") # @json-render/redux Redux / Redux Toolkit adapter for json-render's `StateStore` interface. ## Installation ```bash npm install @json-render/redux @json-render/core @json-render/react redux # or with Redux Toolkit (recommended): npm install @json-render/redux @json-render/core @json-render/react @reduxjs/toolkit ``` ## reduxStateStore Create a `StateStore` backed by a Redux store. ```typescript import { reduxStateStore } from "@json-render/redux"; ``` ### Options
Option Type Required Description
store Store Yes The Redux store instance.
selector {'(state: S) => StateModel'} No Select the json-render slice from the Redux state tree. Defaults to {'(state) => state'}.
dispatch {'(nextState: StateModel, store: Store) => void'} Yes Dispatch an action that replaces the selected slice with the next state.
### Example ```typescript import { configureStore, createSlice } from "@reduxjs/toolkit"; import { reduxStateStore } from "@json-render/redux"; import { StateProvider } from "@json-render/react"; const uiSlice = createSlice({ name: "ui", initialState: { count: 0 } as Record, reducers: { replaceUiState: (_state, action) => action.payload, }, }); const reduxStore = configureStore({ reducer: { ui: uiSlice.reducer }, }); const store = reduxStateStore({ store: reduxStore, selector: (state) => state.ui, dispatch: (next, s) => s.dispatch(uiSlice.actions.replaceUiState(next)), }); ``` ```tsx {/* json-render reads/writes go through Redux */} ``` ## Re-exports
Export Source
StateStore @json-render/core
================================================ FILE: apps/web/app/(main)/docs/api/remotion/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/remotion") # @json-render/remotion Remotion video renderer. Turn JSON timeline specs into video compositions. ## schema The timeline schema for video specs. Use with `defineCatalog` from core. ```typescript import { defineCatalog } from '@json-render/core'; import { schema, standardComponentDefinitions } from '@json-render/remotion'; const catalog = defineCatalog(schema, { components: standardComponentDefinitions, transitions: standardTransitionDefinitions, effects: standardEffectDefinitions, }); ``` ## Renderer The main composition component that renders timeline specs. Use with Remotion's Player or in a Remotion project. ```tsx import { Player } from '@remotion/player'; import { Renderer } from '@json-render/remotion'; function VideoPlayer({ spec }) { return ( ); } ``` ### Custom Components Pass custom components to the Renderer: ```tsx import { Renderer, standardComponents } from '@json-render/remotion'; const customComponents = { ...standardComponents, MyCustomClip: ({ clip }) =>
{clip.props.text}
, }; ``` ## Standard Components Pre-built video components included in the package: ```typescript import { TitleCard, // Full-screen title with subtitle ImageSlide, // Full-screen image display SplitScreen, // Two-column layout QuoteCard, // Quote with attribution StatCard, // Large statistic display LowerThird, // Name/title overlay TextOverlay, // Centered text overlay TypingText, // Terminal typing animation LogoBug, // Corner logo watermark VideoClip, // Video playback } from '@json-render/remotion'; ``` ### TitleCard Props ```typescript { title: string; subtitle?: string; backgroundColor?: string; // default: "#1a1a1a" textColor?: string; // default: "#ffffff" } ``` ### TypingText Props ```typescript { text: string; charsPerSecond?: number; // default: 15 showCursor?: boolean; // default: true cursorChar?: string; // default: "|" fontFamily?: string; // default: "monospace" fontSize?: number; // default: 48 textColor?: string; // default: "#00ff00" backgroundColor?: string; // default: "#1e1e1e" } ``` ## Catalog Definitions Pre-built definitions for creating catalogs: ```typescript import { standardComponentDefinitions, // All standard component definitions standardTransitionDefinitions, // fade, slideLeft, slideRight, etc. standardEffectDefinitions, // kenBurns, pulseGlow, colorShift } from '@json-render/remotion'; // Use in your catalog const catalog = defineCatalog(schema, { components: { ...standardComponentDefinitions, // Add custom components }, transitions: standardTransitionDefinitions, effects: standardEffectDefinitions, }); ``` ## Hooks & Utilities ### useTransition Calculate transition styles for a clip based on current frame: ```typescript import { useTransition } from '@json-render/remotion'; import { useCurrentFrame } from 'remotion'; function MyComponent({ clip }) { const frame = useCurrentFrame(); const transition = useTransition(clip, frame); return (
Content
); } ``` ### ClipWrapper Automatically apply transitions to clip content: ```tsx import { ClipWrapper } from '@json-render/remotion'; function MyClip({ clip }) { return (
My content with automatic transitions
); } ``` ## Types ### TimelineSpec ```typescript interface TimelineSpec { composition: { id: string; fps: number; width: number; height: number; durationInFrames: number; }; tracks: Track[]; clips: Clip[]; audio: { tracks: AudioTrack[]; }; } ``` ### Clip ```typescript interface Clip { id: string; trackId: string; component: string; props: Record; from: number; durationInFrames: number; transitionIn?: { type: string; durationInFrames: number; }; transitionOut?: { type: string; durationInFrames: number; }; } ``` ### TransitionStyles ```typescript interface TransitionStyles { opacity: number; transform: string; } ``` ### ComponentRegistry ```typescript type ClipComponent = React.ComponentType<{ clip: Clip }>; type ComponentRegistry = Record; ``` ## Transitions Available transition types: - `fade` - Opacity fade in/out - `slideLeft` - Slide from right - `slideRight` - Slide from left - `slideUp` - Slide from bottom - `slideDown` - Slide from top - `zoom` - Scale zoom in/out - `wipe` - Horizontal wipe ================================================ FILE: apps/web/app/(main)/docs/api/shadcn/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/shadcn") # @json-render/shadcn Pre-built [shadcn/ui](https://ui.shadcn.com/) components for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`. ## Installation ```bash npm install @json-render/shadcn @json-render/core @json-render/react zod ``` Your app must have Tailwind CSS configured. ## Entry Points
Entry Point Exports Use For
@json-render/shadcn shadcnComponents React implementations
@json-render/shadcn/catalog shadcnComponentDefinitions Catalog schemas (no React dependency, safe for server)
## Usage Pick the components you need from the standard definitions: ```typescript import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog"; import { defineRegistry } from "@json-render/react"; import { shadcnComponents } from "@json-render/shadcn"; // Catalog: pick definitions const catalog = defineCatalog(schema, { components: { Card: shadcnComponentDefinitions.Card, Stack: shadcnComponentDefinitions.Stack, Heading: shadcnComponentDefinitions.Heading, Button: shadcnComponentDefinitions.Button, Input: shadcnComponentDefinitions.Input, }, actions: {}, }); // Registry: pick matching implementations const { registry } = defineRegistry(catalog, { components: { Card: shadcnComponents.Card, Stack: shadcnComponents.Stack, Heading: shadcnComponents.Heading, Button: shadcnComponents.Button, Input: shadcnComponents.Input, }, }); ``` State actions (`setState`, `pushState`, `removeState`) are built into the React schema and handled by `ActionProvider` automatically. You don't need to declare them in your catalog. ## Extending with Custom Components Add custom components alongside standard ones: ```typescript import { z } from "zod"; const catalog = defineCatalog(schema, { components: { // Standard Card: shadcnComponentDefinitions.Card, Stack: shadcnComponentDefinitions.Stack, Button: shadcnComponentDefinitions.Button, // Custom Metric: { props: z.object({ label: z.string(), value: z.string(), trend: z.enum(["up", "down", "neutral"]).nullable(), }), description: "KPI metric display", }, }, actions: {}, }); const { registry } = defineRegistry(catalog, { components: { Card: shadcnComponents.Card, Stack: shadcnComponents.Stack, Button: shadcnComponents.Button, Metric: ({ props }) => (
{props.label} {props.value}
), }, }); ``` ## Available Components ### Layout
Component Description
Card Container card with optional title, description, maxWidth, centered
Stack Flex container with direction, gap, align, justify
Grid Grid layout with columns (1-6) and gap
Separator Visual separator line with orientation
### Navigation
Component Description
Tabs Tabbed navigation with tabs array, defaultValue, value
Accordion Collapsible sections with items array and type (single/multiple)
Collapsible Single collapsible section with title and defaultOpen
Pagination Page navigation with totalPages and page
### Overlay
Component Description
Dialog Modal dialog with title, description, openPath
Drawer Bottom drawer with title, description, openPath
Tooltip Hover tooltip with content and text
Popover Click-triggered popover with trigger and content
DropdownMenu Dropdown menu with label and items array
### Content
Component Description
Heading Heading text with level (h1-h4)
Text Paragraph with variant (body, caption, muted, lead, code)
Image Image with alt, width, height
Avatar User avatar with src, name, size
Badge Status badge with text and variant
Alert Alert banner with title, message, type
Carousel Horizontally scrollable carousel with items
Table Data table with columns and rows
### Feedback
Component Description
Progress Progress bar with value, max, label
Skeleton Loading placeholder with width, height, rounded
Spinner Loading spinner with size and label
### Input
Component Description
Button Clickable button with label, variant, disabled
Link Anchor link with label and href
Input Text input with label, name, type, placeholder, value, checks
Textarea Multi-line text input with label, name, placeholder, rows, value, checks
Select Dropdown select with label, name, options, value, checks
Checkbox Checkbox with label, name, checked
Radio Radio button group with label, name, options, value
Switch Toggle switch with label, name, checked
Slider Range slider with label, min, max, step, value
Toggle Toggle button with label, pressed, variant
ToggleGroup Group of toggle buttons with items, type, value
ButtonGroup Group of buttons with buttons array and selected
## Notes - The `/catalog` entry point has no React dependency -- use it for server-side prompt generation - Components use Tailwind CSS classes -- your app must have Tailwind configured - Component implementations use bundled shadcn/ui primitives (not your app's `components/ui/`) - Form inputs support `checks` for validation (type + message pairs) - Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select` ================================================ FILE: apps/web/app/(main)/docs/api/solid/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata"; export const metadata = pageMetadata("docs/api/solid"); # @json-render/solid SolidJS components, providers, and hooks for rendering json-render specs. ## Installation Peer dependencies: `solid-js ^1.9.0` and `zod ^4.0.0`. ## Providers ### StateProvider ```tsx console.log(changes)} > {/* children */} ```
Prop Type Description
store StateStore External store (controlled mode). When provided,{" "} initialState and onStateChange are ignored.
initialState Record<string, unknown> Initial state model for uncontrolled mode.
onStateChange {"(changes: Array<{ path: string; value: unknown }>) => void"} Called for uncontrolled state updates.
### ActionProvider ```tsx {} }} navigate={(path) => {}} > {/* children */} ``` ### VisibilityProvider ```tsx {/* children */} ``` ### ValidationProvider ```tsx Boolean(value) }}> {/* children */} ``` ### JSONUIProvider Combined provider wrapper for state, visibility, validation, and actions. ```tsx ``` ## defineRegistry Create a typed component registry and action helpers from a catalog. ```tsx const { registry, handlers, executeAction } = defineRegistry(catalog, { components: { Card: (renderProps) =>
{renderProps.children}
, Button: (renderProps) => ( ), }, actions: { submit: async (params, setState, state) => { // custom action logic }, }, }); ``` ## Components ### Renderer ```tsx ``` Renders a `Spec` tree using your registry. ### createRenderer Build an app-level renderer from catalog + components: ```tsx const AppRenderer = createRenderer(catalog, { Card: (renderProps) =>
{renderProps.children}
, }); {}} />; ``` ## Hooks - `useStateStore()` - `useStateValue(path)` - returns an accessor - `useStateBinding(path)` - returns `[Accessor, setValue]` - `useVisibility()` / `useIsVisible(condition)` - `useActions()` / `useAction(binding)` - `useValidation()` / `useOptionalValidation()` - `useFieldValidation(path, config)` - returns accessor-backed `state`, `errors`, and `isValid` - `useBoundProp(value, bindingPath)` - `useUIStream(options)` - `useChatUI(options)` ## Built-in Actions `ActionProvider` handles these built-in actions: - `setState` - `pushState` - `removeState` - `validateForm` ## Component Props Registry components receive: ```ts interface ComponentRenderProps

> { element: UIElement; children?: JSX.Element; emit: (event: string) => void; on: (event: string) => EventHandle; bindings?: Record; loading?: boolean; } ``` Use `emit("event")` to dispatch event bindings. Use `on("event")` to access `EventHandle` metadata (`bound`, `shouldPreventDefault`, `emit`). ## Reactivity Notes - Keep changing reads in JSX expressions, `createMemo`, or `createEffect`. - Avoid props destructuring in component signatures when you need live updates. - `StateProvider` and other contexts expose getter-backed values so consumers read live signals. - `useStateValue`, `useStateBinding`, and `useFieldValidation` expose reactive accessors; call them as functions. ================================================ FILE: apps/web/app/(main)/docs/api/svelte/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/svelte") # @json-render/svelte Svelte 5 components, providers, and helpers for rendering json-render specs. ## Installation Peer dependencies: `svelte ^5.0.0` and `zod ^4.0.0`. ## Components ### Renderer ```svelte ``` Renders a spec with your component registry. If `spec` is `null`, it renders nothing. ### JsonUIProvider Convenience wrapper around `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`. ```svelte ``` ## defineRegistry Create a typed component registry and action handlers from a catalog. ```typescript import { defineRegistry } from "@json-render/svelte"; const { registry, handlers, executeAction } = defineRegistry(catalog, { components: { Card, Button, }, actions: { submit: async (params, setState, state) => { // custom action logic }, }, }); ``` `handlers` is designed for `JsonUIProvider`/`ActionProvider`. `executeAction` is an imperative helper. ## Component Props Registry components receive `BaseComponentProps`: ```typescript interface BaseComponentProps { props: TProps; children?: Snippet; emit: (event: string) => void; bindings?: Record; loading?: boolean; } ``` Use `emit("eventName")` to trigger handlers declared in the spec `on` bindings. ## Context Helpers Use these helpers inside Svelte components: - `getStateValue(path)` - read/write state via `.current` - `getBoundProp(() => value, () => bindingPath)` - write back resolved `$bindState` / `$bindItem` values - `isVisible(condition)` - evaluate visibility via `.current` - `getAction(name)` - read a registered action handler via `.current` - `getFieldValidation(ctx, path, config)` - get field validation state + actions For advanced usage, access full contexts: - `getStateContext()` - `getActionContext()` - `getVisibilityContext()` - `getValidationContext()` - `getOptionalValidationContext()` ## Streaming ### createUIStream ```typescript const stream = createUIStream({ api: "/api/generate-ui", onComplete: (spec) => console.log(spec), }); await stream.send("Create a login form"); console.log(stream.spec); console.log(stream.isStreaming); ``` ### createChatUI ```typescript const chat = createChatUI({ api: "/api/chat-ui" }); await chat.send("Build a settings panel"); console.log(chat.messages, chat.isStreaming); ``` ## Schema Export Use `schema` from `@json-render/svelte` when defining catalogs for Svelte specs. ================================================ FILE: apps/web/app/(main)/docs/api/vue/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/vue") # @json-render/vue Vue 3 components, providers, and composables. ## Providers ### StateProvider ```vue ```
Prop Type Description
store StateStore External store (controlled mode). When provided, initialState and onStateChange are ignored.
initialState Record<string, unknown> Initial state model (uncontrolled mode).
onStateChange {'(changes: Array<{ path: string; value: unknown }>) => void'} Callback when state changes (uncontrolled mode). Called once per set or update with all changed entries.
#### External Store (Controlled Mode) Pass a `StateStore` to bypass the internal state and wire json-render to any state management library: ```typescript import { createStateStore, type StateStore } from "@json-render/vue"; const store = createStateStore({ count: 0 }); ``` ```vue ``` ```typescript // Mutate from anywhere — Vue re-renders automatically: store.set("/count", 1); ``` ### ActionProvider ```vue // type ActionHandler = (params: Record) => void | Promise; ``` ### VisibilityProvider ```vue ``` `VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax. ### ValidationProvider ```vue // type ValidationFunction = (value: unknown, args?: object) => boolean | Promise; ``` ## defineRegistry Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types. When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. When passing stubs, any `async () => {}` is sufficient. ```typescript import { h } from "vue"; import { defineRegistry } from "@json-render/vue"; const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => h("div", { class: "card" }, [h("h3", null, props.title), children]), Button: ({ props, emit }) => h("button", { onClick: () => emit("press") }, props.label), }, // Required when catalog declares actions: actions: { submit: async (params) => { /* ... */ }, }, }); // Pass to // ``` ## Components ### Renderer ```vue ``` ### Component Props (via defineRegistry) ```typescript import type { VNode } from "vue"; interface ComponentContext

{ props: P; // Typed props from catalog children?: VNode | VNode[]; // Rendered children (for container components) emit: (event: string) => void; // Emit a named event (always defined) on: (event: string) => EventHandle; // Get event handle with metadata loading?: boolean; bindings?: Record; // State paths from $bindState/$bindItem expressions } interface EventHandle { emit: () => void; // Fire the event shouldPreventDefault: boolean; // Whether any binding requested preventDefault bound: boolean; // Whether any handler is bound } ``` Use `emit("press")` for simple event firing. Use `on("click")` when you need metadata like `shouldPreventDefault`: ```typescript Link: ({ props, on }) => { const click = on("click"); return h("a", { href: props.href, onClick: (e: MouseEvent) => { if (click.shouldPreventDefault) e.preventDefault(); click.emit(); }, }, props.label); }, ``` ### BaseComponentProps Catalog-agnostic base type for building reusable component libraries that are not tied to a specific catalog: ```typescript import type { BaseComponentProps } from "@json-render/vue"; const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => h("div", null, [props.title, children]); ``` ## Composables ### useStateStore ```typescript const { state, // ShallowRef — access with state.value get, // (path: string) => unknown set, // (path: string, value: unknown) => void update, // (updates: Record) => void } = useStateStore(); ``` > **Note:** `state` is a `ShallowRef`, not a plain object. Use `state.value` to read the current state. This differs from the React renderer. ### useStateValue ```typescript const value = useStateValue(path: string); // ComputedRef ``` Returns a `ComputedRef` that automatically updates when the state at `path` changes. Use `.value` to access the current value. ### useStateBinding (deprecated) > **Deprecated.** Use `$bindState` expressions with `bindings` prop instead. ```typescript const [value, setValue] = useStateBinding(path: string); // value: ComputedRef // setValue: (value: T) => void ``` ### useActions ```typescript const { execute } = useActions(); // execute(binding: ActionBinding) => Promise ``` ### useAction ```typescript const { execute, isLoading } = useAction(binding: ActionBinding); // execute: () => Promise // isLoading: ComputedRef ``` ### useIsVisible ```typescript const isVisible = useIsVisible(condition?: VisibilityCondition); ``` ### useFieldValidation ```typescript const { state, // ComputedRef validate, // () => ValidationResult touch, // () => void clear, // () => void errors, // ComputedRef isValid, // ComputedRef } = useFieldValidation(path: string, config?: ValidationConfig); ``` `ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`. ## Differences from `@json-render/react`
API React Vue Note
useStateStore().state StateModel (plain object) ShallowRef<StateModel> Vue reactivity; use state.value
useStateValue() T | undefined ComputedRef<T | undefined> Vue reactivity; use .value
useStateBinding() [T | undefined, setter] [ComputedRef<T | undefined>, setter] Vue reactivity; use value.value
useAction().isLoading boolean ComputedRef<boolean> Vue reactivity; use .value
useFieldValidation().state FieldValidationState ComputedRef<FieldValidationState> Vue reactivity; use .value
useFieldValidation().errors string[] ComputedRef<string[]> Vue reactivity; use .value
useFieldValidation().isValid boolean ComputedRef<boolean> Vue reactivity; use .value
VisibilityContextValue.ctx CoreVisibilityContext ComputedRef<CoreVisibilityContext> Vue reactivity; use ctx.value
children type React.ReactNode VNode | VNode[] Platform-specific
useBoundProp exported exported Same API; returns [value, setValue]
VisibilityProviderProps exported not exported (no props) Vue uses slot, no prop needed
Streaming hooks useUIStream, useChatUI useUIStream, useChatUI Same API; returns Vue Ref values
================================================ FILE: apps/web/app/(main)/docs/api/xstate/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/xstate") # @json-render/xstate [XState Store](https://stately.ai/docs/xstate-store) adapter for json-render's `StateStore` interface. Requires `@xstate/store` v3+. ## Installation ```bash npm install @json-render/xstate @json-render/core @json-render/react @xstate/store ``` ## xstateStoreStateStore Create a `StateStore` backed by an `@xstate/store` atom. ```typescript import { xstateStoreStateStore } from "@json-render/xstate"; ``` ### Options
Option Type Required Description
atom {'Atom'} Yes An @xstate/store atom (from createAtom) holding the json-render state model.
### Example ```typescript import { createAtom } from "@xstate/store"; import { xstateStoreStateStore } from "@json-render/xstate"; import { StateProvider } from "@json-render/react"; const uiAtom = createAtom({ count: 0 }); const store = xstateStoreStateStore({ atom: uiAtom }); ``` ```tsx {/* json-render reads/writes go through @xstate/store */} ``` ## Re-exports
Export Source
StateStore @json-render/core
================================================ FILE: apps/web/app/(main)/docs/api/yaml/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/yaml") # @json-render/yaml YAML wire format for json-render. Progressive rendering and surgical edits via streaming YAML. ## Prompt Generation ### yamlPrompt Generate a YAML-format system prompt from any json-render catalog. Works with catalogs from any renderer. ```typescript function yamlPrompt( catalog: Catalog, options?: YamlPromptOptions ): string ``` ```typescript import { yamlPrompt } from "@json-render/yaml"; const systemPrompt = yamlPrompt(catalog, { mode: "standalone", customRules: ["Always use dark theme"], editModes: ["merge"], }); ``` ### YamlPromptOptions
Option Type Default Description
system string {'\"You are a UI generator that outputs YAML.\"'} Custom system message intro
mode {'\"standalone\" | \"inline\"'} {'\"standalone\"'} Standalone outputs only YAML; inline allows conversational responses with embedded YAML fences
customRules {'string[]'} {'[]'} Additional rules appended to the prompt
editModes {'EditMode[]'} {'[\"merge\"]'} Edit modes to document in the prompt (patch, merge, diff)
## AI SDK Transform ### createYamlTransform Creates a `TransformStream` that intercepts AI SDK stream chunks and converts YAML spec/edit blocks into json-render patch data parts. ```typescript function createYamlTransform( options?: YamlTransformOptions ): TransformStream ``` Recognized fence types: - {'```yaml-spec'} -- Full YAML spec, parsed progressively - {'```yaml-edit'} -- Partial YAML, deep-merged with current spec - {'```yaml-patch'} -- RFC 6902 JSON Patch lines - {'```diff'} -- Unified diff against serialized spec ### pipeYamlRender Convenience wrapper that pipes an AI SDK stream through the YAML transform. Drop-in replacement for `pipeJsonRender` from `@json-render/core`. ```typescript function pipeYamlRender( stream: ReadableStream, options?: YamlTransformOptions ): ReadableStream ``` ```typescript import { pipeYamlRender } from "@json-render/yaml"; import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; const stream = createUIMessageStream({ execute: async ({ writer }) => { writer.merge(pipeYamlRender(result.toUIMessageStream())); }, }); return createUIMessageStreamResponse({ stream }); ``` ## Streaming Parser ### createYamlStreamCompiler Create a streaming YAML compiler that incrementally parses YAML text and emits JSON Patch operations by diffing each successful parse against the previous snapshot. ```typescript function createYamlStreamCompiler( initial?: Partial ): YamlStreamCompiler ``` ```typescript import { createYamlStreamCompiler } from "@json-render/yaml"; const compiler = createYamlStreamCompiler(); compiler.push("root: main\n"); compiler.push("elements:\n main:\n type: Card\n"); const { result, newPatches } = compiler.flush(); ``` ### YamlStreamCompiler
Method Returns Description
push(chunk) {'{ result: T; newPatches: JsonPatch[] }'} Push a chunk of text, returns current result and new patches
flush() {'{ result: T; newPatches: JsonPatch[] }'} Flush remaining buffer, return final result
getResult() T Get the current compiled result
getPatches() {'JsonPatch[]'} Get all patches applied so far
reset(initial?) void Reset to initial state
## Fence Constants Exported string constants for fence detection in custom parsers:
Constant Value
YAML_SPEC_FENCE {'\"```yaml-spec\"'}
YAML_EDIT_FENCE {'\"```yaml-edit\"'}
YAML_PATCH_FENCE {'\"```yaml-patch\"'}
DIFF_FENCE {'\"```diff\"'}
FENCE_CLOSE {'\"```\"'}
## Re-exports from @json-render/core ### diffToPatches Generate RFC 6902 JSON Patch operations that transform one object into another. ```typescript function diffToPatches( oldObj: Record, newObj: Record, basePath?: string ): JsonPatch[] ``` ### deepMergeSpec Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse. ```typescript function deepMergeSpec( base: Record, patch: Record ): Record ``` ================================================ FILE: apps/web/app/(main)/docs/api/zustand/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/api/zustand") # @json-render/zustand Zustand adapter for json-render's `StateStore` interface. Requires Zustand v5+. Zustand v4 is not supported due to breaking API changes in the vanilla store interface. ## Installation ```bash npm install @json-render/zustand @json-render/core @json-render/react zustand ``` ## zustandStateStore Create a `StateStore` backed by a Zustand vanilla store. ```typescript import { zustandStateStore } from "@json-render/zustand"; ``` ### Options
Option Type Required Description
store {'StoreApi'} Yes A Zustand vanilla store (from createStore in zustand/vanilla).
selector {'(state: S) => StateModel'} No Select the json-render slice from the store state. Defaults to the entire state.
updater {'(nextState: StateModel, store: StoreApi) => void'} No Apply a state change back to the store. Defaults to a shallow merge.
### Example ```typescript import { createStore } from "zustand/vanilla"; import { zustandStateStore } from "@json-render/zustand"; import { StateProvider } from "@json-render/react"; const bearStore = createStore(() => ({ count: 0, name: "Bear", })); const store = zustandStateStore({ store: bearStore }); ``` ```tsx {/* json-render reads/writes go through Zustand */} ``` ### Nested Slice ```typescript const appStore = createStore(() => ({ ui: { count: 0 }, auth: { token: null }, })); const store = zustandStateStore({ store: appStore, selector: (s) => s.ui, updater: (next, s) => s.setState({ ui: next }), }); ``` ## Re-exports
Export Source
StateStore @json-render/core
================================================ FILE: apps/web/app/(main)/docs/catalog/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/catalog") # Catalog The catalog defines what AI can generate. It's your guardrail. ## What is a Catalog? A catalog is the vocabulary for your UI. While the [schema](/docs/schemas) defines the grammar (how specs are structured), the catalog defines the vocabulary (what components and actions are available). It lists: - **Components** — UI elements AI can create (with props and optional slots) - **Actions** — Operations AI can trigger - **Functions** — Custom validation or transformation functions ## Creating a Catalog `defineCatalog` is from `@json-render/core`. The `schema` import comes from your platform package (`@json-render/react` or `@json-render/react-native`) and defines the element structure the catalog targets. The catalog definition itself is framework-agnostic. ```typescript import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema' import { z } from 'zod'; const catalog = defineCatalog(schema, { components: { // Define each component with its props schema Card: { props: z.object({ title: z.string(), description: z.string().nullable(), padding: z.enum(['sm', 'md', 'lg']).nullable(), }), slots: ["default"], // Can contain other components description: "Container card for grouping content", }, Metric: { props: z.object({ label: z.string(), value: z.union([z.string(), z.number()]), format: z.enum(['currency', 'percent', 'number']), }), description: "Display a single metric value", }, }, actions: { submit_form: { params: z.object({ formId: z.string(), }), description: 'Submit a form', }, export_data: { params: z.object({ format: z.enum(['csv', 'pdf', 'json']), }), description: 'Export data in various formats', }, }, }); ``` ## Component Definition Each component in the catalog has: ```typescript { props: z.object({...}), // Zod schema for props (use .nullable() for optional) slots?: string[], // Named slots for children (e.g., ["default"]) description?: string, // Help AI understand when to use it } ``` Use `slots: ["default"]` for components that can contain children. The slot name corresponds to where child elements are rendered. ## Generating AI Prompts Use the `catalog.prompt()` method to generate a system prompt for AI: ```typescript // Generate a system prompt from your catalog const systemPrompt = catalog.prompt(); // Or with custom rules for the AI const customPrompt = catalog.prompt({ customRules: [ "Always use Card as the root element for forms", "Group related inputs in a Stack with direction=vertical", ], }); // Pass this to your AI model as the system prompt ``` ## Next Learn how to [register components](/docs/registry) in your registry. ================================================ FILE: apps/web/app/(main)/docs/changelog/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/changelog") # Changelog Notable changes and updates to json-render. ## v0.10.0 February 2026 ### New: `@json-render/vue` Vue 3 renderer for json-render with full feature parity with `@json-render/react`. Data binding, visibility conditions, actions, validation, repeat scopes, streaming, and external store support. ```bash npm install @json-render/core @json-render/vue ``` ```typescript import { h } from "vue"; import { defineRegistry, Renderer } from "@json-render/vue"; import { schema } from "@json-render/vue/schema"; const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => h("div", { class: "card" }, [h("h3", null, props.title), children]), Button: ({ props, emit }) => h("button", { onClick: () => emit("press") }, props.label), }, }); ``` Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`. Composables: `useStateStore`, `useStateValue`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`, `useBoundProp`, `useUIStream`, `useChatUI`. See the [Vue API reference](/docs/api/vue) for details. ### New: `@json-render/xstate` [XState Store](https://stately.ai/docs/xstate-store) (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for any renderer. ```bash npm install @json-render/xstate @xstate/store ``` ```typescript import { createAtom } from "@xstate/store"; import { xstateStoreStateStore } from "@json-render/xstate"; const atom = createAtom({ count: 0 }); const store = xstateStoreStateStore({ atom }); ``` Requires `@xstate/store` v3+. ### New: `$computed` and `$template` Expressions Two new prop expression types for dynamic values: - **`$template`** -- interpolate state values into strings: `{ "$template": "Hello, ${/user/name}!" }` - **`$computed`** -- call registered functions: `{ "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" } } }` Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`. See [Computed Values](/docs/computed-values) for details. ### New: State Watchers Elements can declare a `watch` field to trigger actions when state values change. Useful for cascading dependencies like country/city selects. ```json { "type": "Select", "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] }, "watch": { "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } } } } ``` `watch` is a top-level field on elements (sibling of type/props/children), not inside props. Watchers only fire on value changes, not on initial render. See [Watchers](/docs/watchers) for details. ### New: Cross-Field Validation New built-in validation functions for cross-field comparisons: - `equalTo` -- alias for `matches` with clearer semantics - `lessThan` -- value must be less than another field - `greaterThan` -- value must be greater than another field - `requiredIf` -- required only when a condition field is truthy Validation check args now resolve through `resolvePropValue`, so `$state` expressions work consistently. ### New: `validateForm` Action Built-in action (React) that validates all registered form fields at once and writes `{ valid, errors }` to state: ```json { "on": { "press": [ { "action": "validateForm", "params": { "statePath": "/formResult" } }, { "action": "submitForm" } ] } } ``` ### Improved: shadcn/ui Validation All form components now support `checks` and `validateOn` props: - Checkbox, Radio, Switch added validation support - `validateOn` controls timing: `"change"` (default for Select, Checkbox, Radio, Switch), `"blur"` (default for Input, Textarea), or `"submit"` ### New Examples - **Vue example** -- standalone Vue 3 app with custom components - **Vite Renderers** -- side-by-side React and Vue renderers with shared catalog --- ## v0.9.1 February 2026 ### Fixed: Install failure due to private dependency `@json-render/react`, `@json-render/react-pdf`, and `@json-render/react-native` v0.9.0 failed to install because `@internal/react-state` (a private workspace package) was published as a dependency. The internal package is now bundled into each renderer at build time, so it no longer needs to be resolved from npm. --- ## v0.9.0 February 2026 ### New: External State Store The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode. - Added `StateStore` interface and `createStateStore()` factory to `@json-render/core` - `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop - When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored) - When `store` is omitted, everything works exactly as before (fully backward compatible) - Applied across all platform packages: react, react-native, react-pdf - Store utilities (`createStoreAdapter`, `immutableSetByPath`, `flattenToPointers`) available via `@json-render/core/store-utils` for building custom adapters New adapter packages: `@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`. See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage. ### Changed: `onStateChange` signature updated (breaking) The `onStateChange` callback now receives a single array of changed entries instead of being called once per path. This makes batch updates via `update()` easier to handle: ```ts // Before onStateChange?: (path: string, value: unknown) => void // After onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void ``` The callback is only called when a `set()` or `update()` call actually changes the state. A `set()` call produces a single-element array; an `update()` call produces one array with all changed paths. ### Fixed: Server-safe schema import `@json-render/react` barrel-imports React contexts that call `createContext`, which crashes in Next.js App Router API routes (RSC runtime strips `createContext`). All docs, examples, and skills now import `schema` from `@json-render/react/schema` instead of `@json-render/react`. For combined imports, split into separate `schema` (subpath) and client API (main entry) lines: ```ts import { schema } from "@json-render/react/schema"; import { defineRegistry, Renderer } from "@json-render/react"; ``` ### Fixed: Chaining actions Fixed an issue where chaining multiple actions on the same event (e.g. `setState` followed by a custom action) did not execute all actions. Affected `@json-render/react`, `@json-render/react-native`, and `@json-render/react-pdf`. ### Fixed: Zod array inner type resolution Fixed safely resolving the inner type for Zod arrays in schema introspection, preventing errors when catalog component props use `z.array()`. --- ## v0.8.0 February 2026 ### New: `@json-render/react-pdf` PDF renderer for json-render, powered by [`@react-pdf/renderer`](https://react-pdf.org/). Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI. ```bash npm install @json-render/core @json-render/react-pdf ``` ```typescript import { renderToBuffer } from "@json-render/react-pdf"; import type { Spec } from "@json-render/core"; const spec: Spec = { root: "doc", elements: { doc: { type: "Document", props: { title: "Invoice" }, children: ["page"] }, page: { type: "Page", props: { size: "A4" }, children: ["heading", "table"], }, heading: { type: "Heading", props: { text: "Invoice #1234", level: "h1" }, children: [], }, table: { type: "Table", props: { columns: [ { header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }, ], rows: [ ["Widget A", "$10.00"], ["Widget B", "$25.00"], ], }, children: [], }, }, }; const buffer = await renderToBuffer(spec); ``` Server-side rendering APIs: - `renderToBuffer(spec)` -- render to an in-memory PDF buffer - `renderToStream(spec)` -- render to a readable stream (pipe to HTTP response) - `renderToFile(spec, path)` -- render directly to a file 15 standard components covering document structure (Document, Page), layout (View, Row, Column), content (Heading, Text, Image, Link), data (Table, List), decorative (Divider, Spacer), and page-level (PageNumber). Supports custom catalogs with `defineRegistry`, server-safe imports via `@json-render/react-pdf/server`, and full context support (state, visibility, actions, validation, repeat scopes). --- ## v0.7.0 February 2026 ### New: `@json-render/shadcn` Pre-built [shadcn/ui](https://ui.shadcn.com/) component library for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`. ```bash npm install @json-render/shadcn ``` ```typescript import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog"; import { defineRegistry } from "@json-render/react"; import { shadcnComponents } from "@json-render/shadcn"; const catalog = defineCatalog(schema, { components: { Card: shadcnComponentDefinitions.Card, Button: shadcnComponentDefinitions.Button, Input: shadcnComponentDefinitions.Input, }, actions: {}, }); const { registry } = defineRegistry(catalog, { components: { Card: shadcnComponents.Card, Button: shadcnComponents.Button, Input: shadcnComponents.Input, }, }); ``` Components include: layout (Card, Stack, Grid, Separator), navigation (Tabs, Accordion, Collapsible, Pagination), overlay (Dialog, Drawer, Tooltip, Popover, DropdownMenu), content (Heading, Text, Image, Avatar, Badge, Alert, Carousel, Table), feedback (Progress, Skeleton, Spinner), and input (Button, Link, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Toggle, ToggleGroup, ButtonGroup). See the [API reference](/docs/api/shadcn) for full details. ### New: Event Handles (`on()`) Components now receive an `on(event)` function in addition to `emit(event)`. The `on()` function returns an `EventHandle` with metadata: - `emit()` -- fire the event - `shouldPreventDefault` -- whether any action binding requested `preventDefault` - `bound` -- whether any handler is bound to this event ```tsx Link: ({ props, on }) => { const click = on("click"); return ( { if (click.shouldPreventDefault) e.preventDefault(); click.emit(); }}>{props.label} ); }, ``` ### New: `BaseComponentProps` Catalog-agnostic base type for component render functions. Use when building reusable component libraries (like `@json-render/shadcn`) that are not tied to a specific catalog. ```typescript import type { BaseComponentProps } from "@json-render/react"; const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (

{props.title}{children}
); ``` ### New: Built-in Actions in Schema Schemas can now declare `builtInActions` -- actions that are always available at runtime and automatically injected into prompts. The React schema declares `setState`, `pushState`, and `removeState` as built-in, so they appear in prompts without needing to be listed in catalog `actions`. ### New: `preventDefault` on `ActionBinding` Action bindings now support a `preventDefault` boolean field, allowing the LLM to request that default browser behavior (e.g. navigation on links) be prevented. ### Improved: Stream Transform Text Block Splitting `createJsonRenderTransform()` now properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs. This ensures the AI SDK creates separate text parts, preserving correct interleaving of prose and UI in `message.parts`. ### Improved: `defineRegistry` Actions Requirement `defineRegistry` now conditionally requires the `actions` field only when the catalog declares actions. Catalogs with no actions (e.g. `actions: {}`) no longer need to pass an empty actions object. --- ## v0.6.0 February 2026 ### New: Chat Mode (Inline GenUI) > **Note:** These modes were renamed in v0.12.1 — "Generate" is now "Standalone" and "Chat" is now "Inline". The old names are accepted as deprecated aliases. json-render now supports two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets the AI respond conversationally with embedded UI specs, ideal for chatbots and copilot experiences. ```typescript // Generate mode (default) — AI outputs only JSONL const prompt = catalog.prompt(); // Chat mode — AI outputs text + JSONL inline const chatPrompt = catalog.prompt({ mode: "chat" }); ``` On the server, `pipeJsonRender()` separates text from JSONL patches in a mixed stream: ```typescript import { pipeJsonRender } from "@json-render/core"; import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; const stream = createUIMessageStream({ execute: async ({ writer }) => { writer.merge(pipeJsonRender(result.toUIMessageStream())); }, }); return createUIMessageStreamResponse({ stream }); ``` On the client, `useJsonRenderMessage` extracts the spec and text from message parts: ```tsx import { useJsonRenderMessage } from "@json-render/react"; function ChatMessage({ message }) { const { spec, text, hasSpec } = useJsonRenderMessage(message.parts); return (
{text && {text}} {hasSpec && }
); } ``` ### New: AI SDK Integration First-class Vercel AI SDK support with typed data parts and stream utilities. - `SpecDataPart` type for `data-spec` stream parts (patch, flat, nested payloads) - `SPEC_DATA_PART` / `SPEC_DATA_PART_TYPE` constants for type-safe part filtering - `createJsonRenderTransform()` low-level TransformStream for custom pipelines - `createMixedStreamParser()` for parsing mixed text + JSONL streams ### New: Two-Way Binding Props can now use `$bindState` and `$bindItem` expressions for two-way data binding. The renderer resolves bindings and passes a `bindings` map to components, enabling write-back to state without custom `valuePath` props. ```json { "type": "Input", "props": { "label": "Email", "value": { "$bindState": "/form/email" } } } ``` ```tsx import { useBoundProp } from "@json-render/react"; Input: ({ props, bindings }) => { const [value, setValue] = useBoundProp(props.value, bindings?.value); return setValue(e.target.value)} />; } ``` ### New: Expression-Based Props and Visibility All dynamic expressions now use structured `$state`, `$item`, and `$index` objects instead of string token rewriting. This is simpler, more explicit, and works for both props and visibility conditions. **Props:** ```json { "title": { "$state": "/user/name" } } { "label": { "$item": "title" } } { "position": { "$index": true } } ``` **Visibility:** ```json { "$state": "/isAdmin" } { "$state": "/role", "eq": "admin" } [{ "$state": "/isAdmin" }, { "$state": "/feature" }] { "$or": [{ "$state": "/roleA" }, { "$state": "/roleB" }] } { "$item": "isActive" } { "$index": true, "gt": 0 } ``` Comparison operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`. ### New: React Chat Hooks - `useChatUI()` — full chat hook with message history, streaming, and spec extraction - `useJsonRenderMessage()` — extract spec + text from a message's parts array - `buildSpecFromParts()` / `getTextFromParts()` — utilities for working with AI SDK message parts - `useBoundProp()` — two-way binding hook for `$bindState` / `$bindItem` ### New: Chat Example Full-featured chat example (`examples/chat`) with AI agent, tool calls (crypto, GitHub, Hacker News, weather, search), theme toggle, and streaming inline UI generation. ### Improved: Renderer Performance - `ElementRenderer` is now `React.memo`'d for better performance with repeat lists - `emit` is always defined (never `undefined`) - Repeat scope passes the actual item object, eliminating string token rewriting ### Improved: Utilities - `applySpecPatch()` — typed wrapper for applying a single patch to a Spec - `nestedToFlat()` — convert nested tree specs to flat format - `resolveBindings()` / `resolveActionParam()` — resolve binding paths and action params ### Breaking Changes - `{ $path }` and `{ path }` replaced by `{ $state }`, `{ $item }`, `{ $index }` in props - Visibility: `{ path }` -> `{ $state }`, `{ and/or/not }` -> `{ $and/$or }` with `not` as operator flag - `DynamicValue`: `{ path: string }` -> `{ $state: string }` - `repeat.path` -> `repeat.statePath` - Action params: `path` -> `statePath` in setState action - `actionHandlers` -> `handlers` on `JSONUIProvider` / `ActionProvider` - `AuthState` and `{ auth }` visibility conditions removed (model auth as regular state) - Legacy catalog API removed: `createCatalog`, `generateCatalogPrompt`, `generateSystemPrompt` - React exports removed: `createRendererFromCatalog`, `rewriteRepeatTokens` - Codegen: `traverseTree` -> `traverseSpec` See the [Migration Guide](/docs/migration) for detailed upgrade instructions. --- ## v0.5.0 February 2026 ### New: @json-render/react-native Full React Native renderer with 25+ standard components, data binding, visibility, actions, and dynamic props. Build AI-generated native mobile UIs with the same catalog-driven approach as web. ```tsx import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react-native/schema"; import { standardComponentDefinitions, standardActionDefinitions, } from "@json-render/react-native/catalog"; import { defineRegistry, Renderer } from "@json-render/react-native"; const catalog = defineCatalog(schema, { components: { ...standardComponentDefinitions }, actions: standardActionDefinitions, }); const { registry } = defineRegistry(catalog, { components: {} }); ``` Includes standard components for layout (Container, Row, Column, ScrollContainer, SafeArea, Pressable, Spacer, Divider), content (Heading, Paragraph, Label, Image, Avatar, Badge, Chip), input (Button, TextInput, Switch, Checkbox, Slider, SearchBar), feedback (Spinner, ProgressBar), and composite (Card, ListItem, Modal). ### New: Event System Components now use `emit` to fire named events instead of directly dispatching actions. The element's `on` field maps events to action bindings, decoupling component logic from action handling. ```tsx // Component emits a named event Button: ({ props, emit }) => ( ), // Element spec maps events to actions { "type": "Button", "props": { "label": "Submit" }, "on": { "press": { "action": "submit", "params": { "formId": "main" } } } } ``` ### New: Repeat/List Rendering Elements can now iterate over state arrays using the `repeat` field. Child elements use `{ "$item": "field" }` to read from the current item and `{ "$index": true }` for the current array index. ```json { "type": "Column", "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] } ``` ```json { "type": "Card", "props": { "title": { "$item": "title" } } } ``` ### New: User Prompt Builder Build structured user prompts with optional spec refinement and state context: ```typescript import { buildUserPrompt } from "@json-render/core"; // Fresh generation buildUserPrompt({ prompt: "create a todo app" }); // Refinement (patch-only mode) buildUserPrompt({ prompt: "add a toggle", currentSpec: spec }); // With runtime state buildUserPrompt({ prompt: "show data", state: { todos: [] } }); ``` ### New: Spec Validation Validate spec structure and auto-fix common issues: ```typescript import { validateSpec, autoFixSpec } from "@json-render/core"; const { valid, issues } = validateSpec(spec); const fixed = autoFixSpec(spec); ``` ### Improved: State Management `DataProvider` has been renamed to `StateProvider` with a clearer API. State is now a first-class part of specs. Elements can bind to state via `$state` expressions, and the built-in `setState` action updates state directly. ### Improved: AI Prompts Schema prompts now include streaming best practices, repeat/list examples, and state patching guidance. Schemas can also define `defaultRules` that are always included in generated prompts. ### Improved: Documentation - All documentation pages migrated to MDX - AI-powered documentation chat - Dynamic Open Graph images for all docs pages - Improved playground ### Breaking Changes - `DataProvider` renamed to `StateProvider` - `useData` renamed to `useStateStore`, `useDataValue` to `useStateValue`, `useDataBinding` to `useStateBinding` - `onAction` renamed to `emit` in component context - `DataModel` type renamed to `StateModel` - `Action` type renamed to `ActionBinding` (old name still available but deprecated) --- ## v0.4.0 February 2026 ### New: Custom Schema System Create custom output formats with `defineSchema`. Each renderer now defines its own schema, enabling completely different spec formats for different use cases. ```typescript import { defineSchema } from "@json-render/core"; const mySchema = defineSchema((s) => ({ spec: s.object({ pages: s.array(s.object({ title: s.string(), blocks: s.array(s.ref("catalog.blocks")), })), }), catalog: s.object({ blocks: s.map({ props: s.zod(), description: s.string() }), }), }), { promptTemplate: myPromptTemplate, }); ``` ### New: Component Slots Components can now define which slots they accept. Use `["default"]` for regular children, or named slots like `["header", "footer"]` for more complex layouts. ```typescript const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string() }), slots: ["default"], // accepts children description: "A card container", }, Layout: { props: z.object({}), slots: ["header", "content", "footer"], // named slots description: "Page layout with header, content, footer", }, }, }); ``` ### New: AI Prompt Generation Catalogs now generate AI system prompts automatically with `catalog.prompt()`. The prompt includes all component definitions, props schemas, and action descriptions - ensuring the AI only generates valid specs. ```typescript import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; const catalog = defineCatalog(schema, { components: { /* ... */ }, actions: { /* ... */ }, }); // Generate system prompt for AI const systemPrompt = catalog.prompt(); // Use with any AI SDK const result = await streamText({ model: "claude-haiku-4.5", system: systemPrompt, prompt: userMessage, }); ``` ### New: @json-render/remotion Generate AI-powered videos with Remotion. Define video catalogs, stream timeline specs, and render with the Remotion Player. ```tsx import { Player } from "@remotion/player"; import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion"; const catalog = defineCatalog(schema, { components: standardComponentDefinitions, transitions: standardTransitionDefinitions, }); ``` Includes 10 standard video components (TitleCard, TypingText, SplitScreen, etc.), 7 transition types, and the ClipWrapper utility for custom components. ### New: SpecStream SpecStream is json-render's streaming format for progressively building specs from JSONL patches. The new compiler API makes it easy to process streaming AI responses. ```typescript import { createSpecStreamCompiler } from "@json-render/core"; const compiler = createSpecStreamCompiler(); // Process streaming chunks const { result, newPatches } = compiler.push(chunk); setSpec(result); // Update UI with partial result ``` ### Improved: Dashboard Example The dashboard example is now a full-featured accounting dashboard with: - Persistent SQLite database with Drizzle ORM - RESTful API for customers, invoices, expenses, accounts - Draggable widget reordering - AI-powered widget generation with streaming - Real data binding to database records ### Improved: Documentation - Interactive playground for testing specs - New guides: Custom Schema, Streaming, Code Export - Full API reference for all packages - Integration guides: A2UI, AG-UI, Adaptive Cards, OpenAPI ### Breaking Changes - `UITree` type renamed to `Spec` - Schema is now imported from renderer packages (`@json-render/react`) not core - `defineCatalog` now requires a schema as first argument --- ## v0.3.0 January 2026 Internal release with codegen foundations. - Added `@json-render/codegen` package (spec traversal and JSX serialization) - Configurable AI model via environment variables - Documentation improvements and bug fixes *Note: Only @json-render/core was published to npm for this release.* --- ## v0.2.0 January 2026 Initial public release. - Core catalog and spec types - React renderer with contexts for data, actions, visibility - AI prompt generation from catalogs - Basic streaming support - Dashboard example application ================================================ FILE: apps/web/app/(main)/docs/code-export/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/code-export") # Code Export Export generated UI as standalone code for your framework. ## Overview While json-render is designed for dynamic rendering, you can export generated UI as static code. The code generation is intentionally project-specific so you have full control over: - Component templates (standalone, no json-render dependencies) - Package.json and project structure - Framework-specific patterns (Next.js, Remix, etc.) - How data is passed to components ## Architecture Code export is split into two parts: ### 1. @json-render/codegen (utilities) Framework-agnostic utilities for building code generators: ```typescript import { traverseSpec, // Walk the UI spec collectUsedComponents, // Get all component types used collectStatePaths, // Get all data binding paths collectActions, // Get all action names serializeProps, // Convert props to JSX string } from '@json-render/codegen'; ``` ### 2. Your Project (generator) Custom code generator specific to your project and framework: ```typescript // lib/codegen/generator.ts import { collectUsedComponents, serializeProps } from '@json-render/codegen'; export function generateNextJSProject(spec: Spec): GeneratedFile[] { const components = collectUsedComponents(spec); return [ { path: 'package.json', content: '...' }, { path: 'app/page.tsx', content: '...' }, // ... component files ]; } ``` ## Example: Next.js Export See the dashboard example for a complete implementation that exports: - `package.json` - Dependencies and scripts - `tsconfig.json` - TypeScript config - `next.config.js` - Next.js config - `app/layout.tsx` - Root layout - `app/globals.css` - Global styles - `app/page.tsx` - Generated page with data - `components/ui/*.tsx` - Standalone components ## Standalone Components The exported components are standalone with no json-render dependencies. They receive data as props instead of using hooks: ```tsx // Generated component (standalone) interface MetricProps { label: string; statePath: string; data?: Record; } export function Metric({ label, statePath, data }: MetricProps) { const value = data ? getByPath(data, statePath) : undefined; return (
{label} {formatValue(value)}
); } ``` ## Using the Utilities ### traverseSpec ```typescript import { traverseSpec } from '@json-render/codegen'; traverseSpec(spec, (element, key, depth, parent) => { console.log(' '.repeat(depth * 2) + `${key}: ${element.type}`); }); ``` ### collectUsedComponents ```typescript import { collectUsedComponents } from '@json-render/codegen'; const components = collectUsedComponents(spec); // Set { 'Card', 'Metric', 'Chart', 'Table' } // Generate only the needed component files for (const component of components) { files.push({ path: `components/ui/${component.toLowerCase()}.tsx`, content: componentTemplates[component], }); } ``` ### serializeProps ```typescript import { serializeProps } from '@json-render/codegen'; const propsStr = serializeProps({ title: 'Dashboard', columns: 3, disabled: true, }); // 'title="Dashboard" columns={3} disabled' ``` ## Try It Run the dashboard example and click "Export Project" to see code generation in action: ```bash cd examples/dashboard pnpm dev # Open http://dashboard-demo.json-render.localhost:1355 # Generate a widget, then click "Export Project" ``` ================================================ FILE: apps/web/app/(main)/docs/computed-values/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/computed-values") # Computed Values Derive dynamic prop values using registered functions or string templates. ## `$template` — String Interpolation Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer: ```json { "type": "Text", "props": { "text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." } }, "children": [] } ``` If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages." Missing paths resolve to an empty string. ## `$computed` — Registered Functions Use `{ "$computed": "", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.): ```json { "type": "Text", "props": { "text": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } } }, "children": [] } ``` ### Registering Functions Functions are registered in the catalog and provided at runtime. **Catalog definition (for AI prompt generation):** ```typescript import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; const catalog = defineCatalog(schema, { components: { /* ... */ }, functions: { fullName: { description: 'Combines first and last name into a full name', }, formatCurrency: { description: 'Formats a number as currency', }, }, }); ``` **Runtime implementation:** ```tsx import { JSONUIProvider } from '@json-render/react'; const functions = { fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(), formatCurrency: (args) => { const value = Number(args.value ?? 0); return new Intl.NumberFormat('en-US', { style: 'currency', currency: (args.currency as string) ?? 'USD', }).format(value); }, }; ``` ### Using with `createRenderer` ```tsx const MyRenderer = createRenderer(catalog, components); ``` ## Combining Expressions `$computed` args can use any expression type. This example computes a total from repeat item fields: ```json { "$computed": "lineTotal", "args": { "price": { "$item": "price" }, "quantity": { "$item": "quantity" } } } ``` ## Next - [Watchers](/docs/watchers) — react to state changes with cascading actions - [Data Binding](/docs/data-binding) — all expression types - [Validation](/docs/validation) — validate form inputs ================================================ FILE: apps/web/app/(main)/docs/custom-schema/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/custom-schema") # Custom Schema & Renderer Build your own schema and renderer with `@json-render/core`. ## Overview `@json-render/core` is schema-agnostic. While `@json-render/react` provides a ready-to-use schema and renderer, you can create your own to match any JSON structure - whether it's a domain-specific format, an existing protocol, or something entirely custom. ## 1. Define Your Schema Start by defining the JSON structure your system will use. Here's an example of a simple dashboard schema: ```json { "layout": "grid", "columns": 2, "widgets": [ { "type": "metric", "title": "Revenue", "value": "$12,345", "trend": "up" }, { "type": "chart", "title": "Sales", "chartType": "line", "dataKey": "salesData" }, { "type": "table", "title": "Recent Orders", "columns": ["id", "customer", "amount"], "dataKey": "orders" } ] } ``` ## 2. Create the Catalog Define a catalog that describes your components and validates props using `defineCatalog` — see [Catalog](/docs/catalog). ```typescript import { defineCatalog } from '@json-render/core'; import { z } from 'zod'; export const dashboardCatalog = defineCatalog(mySchema, { components: { metric: { description: 'Displays a single metric value', props: z.object({ title: z.string(), value: z.string(), trend: z.enum(['up', 'down', 'flat']).optional(), change: z.string().optional(), }), }, chart: { description: 'Renders a chart visualization', props: z.object({ title: z.string(), chartType: z.enum(['line', 'bar', 'pie', 'area']), dataKey: z.string(), height: z.number().optional(), }), }, table: { description: 'Displays tabular data', props: z.object({ title: z.string(), columns: z.array(z.string()), dataKey: z.string(), pageSize: z.number().optional(), }), }, text: { description: 'Displays text content', props: z.object({ content: z.string(), variant: z.enum(['heading', 'body', 'caption']).optional(), }), }, }, }); ``` ## 3. Define the Root Schema Create a schema for the overall document structure: ```typescript import { z } from 'zod'; const WidgetSchema = z.object({ type: z.string(), title: z.string().optional(), // Additional props validated by catalog }).passthrough(); export const DashboardSchema = z.object({ layout: z.enum(['grid', 'stack', 'tabs']), columns: z.number().optional(), widgets: z.array(WidgetSchema), }); export type Dashboard = z.infer; export type Widget = z.infer; ``` ## 4. Build the Renderer Create a renderer that maps your schema to React components: ```tsx import React from 'react'; import { dashboardCatalog } from './catalog'; import type { Dashboard, Widget } from './schema'; // Widget component registry const widgetComponents: Record> = { metric: ({ title, value, trend, change }) => (

{title}

{value}

{trend && (

{trend === 'up' ? '+' : '-'}{change}

)}
), chart: ({ title, chartType, data }) => (

{title}

{/* Your chart library here */} {chartType} chart
), table: ({ title, columns, data }) => (

{title}

{columns.map((col: string) => ( ))} {data?.map((row: any, i: number) => ( {columns.map((col: string) => ( ))} ))}
{col}
{row[col]}
), text: ({ content, variant = 'body' }) => { const className = { heading: 'text-xl font-bold', body: 'text-base', caption: 'text-sm text-muted-foreground', }[variant]; return

{content}

; }, }; // Main renderer export function DashboardRenderer({ spec, data = {}, }: { spec: Dashboard; data?: Record; }) { const layoutClass = { grid: `grid gap-4 ${spec.columns ? `grid-cols-${spec.columns}` : 'grid-cols-2'}`, stack: 'flex flex-col gap-4', tabs: 'space-y-4', }[spec.layout]; return (
{spec.widgets.map((widget, index) => { const Component = widgetComponents[widget.type]; if (!Component) { console.warn(`Unknown widget type: ${widget.type}`); return null; } // Resolve data references const widgetData = widget.dataKey ? data[widget.dataKey] : undefined; return ( ); })}
); } ``` ## 5. Generate LLM Prompts Use the catalog to generate system prompts for AI: ```typescript const systemPrompt = dashboardCatalog.prompt({ customRules: [ 'Use metric widgets for single KPI values', 'Use chart widgets for time-series data', 'Use table widgets for lists of records', 'Limit dashboards to 6 widgets maximum', ], }); // Use with any LLM const response = await generateText({ model: 'gpt-4', system: systemPrompt, prompt: 'Create a sales dashboard with revenue, orders, and a chart', }); ``` ## 6. Validate Specs Validate incoming specs against your schema. Use `catalog.validate()` to check AI output against the catalog's Zod schema: ```typescript function validateDashboard(spec: unknown) { // Validate root structure const rootResult = DashboardSchema.safeParse(spec); if (!rootResult.success) { return { valid: false, errors: rootResult.error.errors }; } // Validate each widget's props against the catalog const result = dashboardCatalog.validate(spec); if (!result.success) { return { valid: false, errors: result.error.errors }; } return { valid: true, errors: [] }; } ``` ## Usage Example ```tsx 'use client'; import { useState } from 'react'; import { DashboardRenderer } from './renderer'; import type { Dashboard } from './schema'; const initialSpec: Dashboard = { layout: 'grid', columns: 2, widgets: [ { type: 'metric', title: 'Revenue', value: '$12,345', trend: 'up' }, { type: 'metric', title: 'Orders', value: '156', trend: 'up' }, { type: 'chart', title: 'Sales Trend', chartType: 'line', dataKey: 'sales' }, { type: 'table', title: 'Recent Orders', columns: ['id', 'customer', 'amount'], dataKey: 'orders' }, ], }; const data = { sales: [/* chart data */], orders: [ { id: '001', customer: 'Acme Inc', amount: '$500' }, { id: '002', customer: 'Globex', amount: '$750' }, ], }; export function MyDashboard() { const [spec, setSpec] = useState(initialSpec); return ; } ``` ## Next See how to integrate with [A2UI](/docs/a2ui) or [Adaptive Cards](/docs/adaptive-cards) protocols. ================================================ FILE: apps/web/app/(main)/docs/data-binding/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/data-binding") # Data Binding Connect UI elements to dynamic data using expressions in your JSON specs. ## State Model Every spec can include a `state` object that holds the data your UI reads from: ```json { "root": "greeting", "elements": { "greeting": { "type": "Text", "props": { "content": { "$state": "/user/name" } }, "children": [] } }, "state": { "user": { "name": "Alice" } } } ``` State can also be provided programmatically at runtime. In `@json-render/react`, this is done via `StateProvider` and hooks like `useStateStore`. See the [React API reference](/docs/api/react) for details. ## JSON Pointer Paths All paths in json-render follow JSON Pointer (RFC 6901). A path is a string of `/`-separated tokens starting from the root: ``` Given this state: { "user": { "name": "Alice", "email": "alice@example.com" }, "todos": [ { "title": "Buy milk", "done": false }, { "title": "Walk dog", "done": true } ] } "/user/name" -> "Alice" "/user/email" -> "alice@example.com" "/todos/0/title" -> "Buy milk" "/todos/1/done" -> true ``` ## Expressions Expressions are special objects you place in props to read dynamic values instead of hardcoding them. There are six expression types. ### `$state` — Read from state Use `{ "$state": "/path" }` in any prop to read a value from the state model: ```json { "type": "Card", "props": { "title": { "$state": "/user/name" }, "subtitle": { "$state": "/user/email" } }, "children": [] } ``` If state contains `{ "user": { "name": "Alice", "email": "alice@example.com" } }`, the Card renders with title "Alice" and subtitle "alice@example.com". ### `$item` — Read from the current repeat item Use `{ "$item": "field" }` inside a [repeat](#repeat) to read a field from the current array item: ```json { "type": "Text", "props": { "content": { "$item": "title" } }, "children": [] } ``` Use `{ "$item": "" }` to get the entire item object. ### `$index` — Current repeat index Use `{ "$index": true }` inside a [repeat](#repeat) to get the current array index (zero-based number): ```json { "type": "Text", "props": { "content": { "$index": true } }, "children": [] } ``` ## Repeat The `repeat` field on an element renders its children once per item in a state array. It is a top-level field on the element, sibling of `type`, `props`, and `children` — not inside `props`. ```json { "root": "todo-list", "elements": { "todo-list": { "type": "Column", "props": { "gap": 8 }, "repeat": { "statePath": "/todos", "key": "id" }, "children": ["todo-item"] }, "todo-item": { "type": "Card", "props": { "title": { "$item": "title" }, "subtitle": { "$item": "description" } }, "children": [] } }, "state": { "todos": [ { "id": "1", "title": "Buy milk", "description": "2% or whole" }, { "id": "2", "title": "Walk dog", "description": "Around the park" } ] } } ``` - `repeat.statePath` — JSON Pointer to the state array - `repeat.key` — field name on each item to use as a stable key for rendering Inside `todo-item`, `{ "$item": "title" }` reads the `title` field from whichever array item is currently being rendered. `{ "$index": true }` would return `0` for the first item, `1` for the second, and so on. ## Two-Way Binding with `$bindState` Form components use `{ "$bindState": "/path" }` on their natural value prop for two-way binding. The component reads from and writes to the state path. ### Value prop (text inputs) ```json { "type": "TextInput", "props": { "value": { "$bindState": "/form/email" }, "placeholder": "Enter your email" }, "children": [] } ``` ### Checked prop (switches, checkboxes) ```json { "type": "Switch", "props": { "label": "Enable notifications", "checked": { "$bindState": "/settings/notifications" } }, "children": [] } ``` ### Pressed prop (toggle buttons) ```json { "type": "ToggleButton", "props": { "label": "Bold", "pressed": { "$bindState": "/editor/bold" } }, "children": [] } ``` ## Two-Way Binding with `$bindItem` Inside a repeat scope, use `{ "$bindItem": "field" }` to bind to a field on the current item: ```json { "type": "Switch", "props": { "label": "Done", "checked": { "$bindItem": "completed" } }, "children": [] } ``` Use `{ "$bindItem": "" }` to bind to the entire item. `statePath` is not used for component binding. It remains for `repeat.statePath` (array iteration path) and action params like `setState.statePath` (target path for mutations). ## Conditional Props Use `$cond` / `$then` / `$else` to pick a prop value based on a condition: ```json { "type": "Badge", "props": { "label": { "$cond": { "$state": "/user/isAdmin" }, "$then": "Admin", "$else": "Member" } }, "children": [] } ``` The condition uses the same [visibility](/docs/visibility) expression format. ## Template Strings Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax: ```json { "type": "Text", "props": { "text": { "$template": "Welcome back, ${/user/name}!" } }, "children": [] } ``` See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions. ## Quick Reference
Expression Syntax Context
{"$state"} {'{ "$state": "/path" }'} Anywhere
{"$item"} {'{ "$item": "field" }'} Inside repeat only
{"$index"} {'{ "$index": true }'} Inside repeat only
{"$cond"} {'{ "$cond": ..., "$then": ..., "$else": ... }'} Anywhere
{"$bindState"} {'{ "$bindState": "/path" }'} Form components (value, checked, pressed)
{"$bindItem"} {'{ "$bindItem": "field" }'} Form components inside repeat
{"$template"} {'{ "$template": "Hello, ${/name}!" }'} Anywhere (string props)
{"$computed"} {'{ "$computed": "fn", "args": { ... } }'} Anywhere (requires registered function)
## External Store (Controlled Mode) For advanced use cases, you can pass a `StateStore` to `StateProvider` to use your own state management (Redux, Zustand, XState, etc.) instead of the built-in internal store: ```tsx import { createStateStore, type StateStore } from "@json-render/react"; const store = createStateStore({ user: { name: "Alice" } }); {children} // Mutate from anywhere — React re-renders automatically: store.set("/user/name", "Bob"); ``` When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. See the [React API reference](/docs/api/react#external-store-controlled-mode) for the full `StateStore` interface. ## Next - [Visibility](/docs/visibility) — conditionally show or hide elements - [Action handlers](/docs/registry#action-handlers) — respond to user interactions - [React API reference](/docs/api/react) — React-specific hooks for programmatic state access ================================================ FILE: apps/web/app/(main)/docs/generation-modes/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/generation-modes") # Generation Modes json-render supports two modes for AI-generated UI: **Standalone mode** for standalone UI and **Inline mode** for inline UI within a conversation. The mode controls how the AI formats its output and how your app processes the stream. The underlying JSONL patch format is the same in both modes. ## Standalone Mode In standalone mode, the AI outputs **only JSONL patches** — no prose, no markdown. The entire response is a UI spec. This is the default mode and is ideal for: - Playground and builder tools - Form generators - Dashboard builders - Any UI where the generated interface is the whole response ### Setup ```typescript import { streamText } from "ai"; // Standalone mode is the default (no mode option needed) const systemPrompt = catalog.prompt({ customRules: [ "Use Card as root for forms and small UIs.", "Use Grid for multi-column layouts.", ], }); const result = streamText({ model: "anthropic/claude-haiku-4.5", system: systemPrompt, prompt: userPrompt, }); ``` ### Client On the client, use `useUIStream` from `@json-render/react` or the lower-level `createSpecStreamCompiler` from `@json-render/core` to compile the JSONL stream into a spec: ```tsx import { useUIStream } from "@json-render/react"; function Playground() { const { spec, isStreaming, send } = useUIStream({ api: "/api/generate", }); return ( ); } ``` ### Example output The AI outputs only JSONL — one patch per line, no surrounding text: ``` {"op":"add","path":"/root","value":"card-1"} {"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Sign In"},"children":["email","password","submit"]}} {"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email","type":"email"}}} {"op":"add","path":"/elements/password","value":{"type":"Input","props":{"label":"Password","name":"password","type":"password"}}} {"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Sign In"}}} ``` ## Inline Mode In inline mode, the AI responds **conversationally first**, then outputs JSONL patches on their own lines. Text-only replies are allowed when no UI is needed (e.g. greetings, clarifying questions). This is ideal for: - AI chatbots with rich UI responses - Copilot experiences - Educational assistants - Any conversational interface where generated UI is embedded in chat messages ### Setup ```typescript import { streamText } from "ai"; import { pipeJsonRender } from "@json-render/core"; import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; // Enable inline mode const systemPrompt = catalog.prompt({ mode: "inline" }); const result = streamText({ model: yourModel, system: systemPrompt, messages, }); // In your API route, pipe the stream through pipeJsonRender // to separate text from JSONL patches const stream = createUIMessageStream({ execute: async ({ writer }) => { writer.merge(pipeJsonRender(result.toUIMessageStream())); }, }); return createUIMessageStreamResponse({ stream }); ``` `pipeJsonRender` inspects each line of the AI's response. Lines that parse as JSONL patches are emitted as `data-spec` parts (which the renderer picks up). Everything else is passed through as text. ### Client On the client, use `useJsonRenderMessage` from `@json-render/react` to extract the spec from a chat message's parts: ```tsx import { useChat } from "@ai-sdk/react"; import { useJsonRenderMessage } from "@json-render/react"; function Chat() { const { messages, input, handleInputChange, handleSubmit } = useChat(); return (
{messages.map((msg) => ( ))} {/* input form */}
); } function ChatMessage({ message }) { const { spec } = useJsonRenderMessage(message.parts); return (
{/* Render text parts */} {message.parts .filter((p) => p.type === "text") .map((p, i) =>

{p.text}

)} {/* Render the generated UI inline */} {spec && ( )}
); } ``` ### Example output The AI writes a brief explanation, then JSONL patches on their own lines: ``` Here's a dashboard showing the latest crypto prices: {"op":"add","path":"/root","value":"dashboard"} {"op":"add","path":"/state/prices","value":[{"name":"Bitcoin","price":98450},{"name":"Ethereum","price":3120}]} {"op":"add","path":"/elements/dashboard","value":{"type":"Grid","props":{"columns":"2"},"children":["btc","eth"]}} {"op":"add","path":"/elements/btc","value":{"type":"Metric","props":{"label":"Bitcoin","value":{"$state":"/prices/0/price"}}}} {"op":"add","path":"/elements/eth","value":{"type":"Metric","props":{"label":"Ethereum","value":{"$state":"/prices/1/price"}}}} ``` If the user asks a simple question ("what does BTC stand for?"), the AI replies with text only — no JSONL. ## Quick Comparison
Standalone Inline
Output format JSONL only Text + JSONL
Text-only replies No Yes
System prompt {"catalog.prompt()"} {'catalog.prompt({ mode: "inline" })'}
Stream utility {"useUIStream"} {"pipeJsonRender"}{" + "}{"useJsonRenderMessage"}
Typical use case Playground, builders Chatbots, copilots
Both modes use the same JSONL patch format (RFC 6902) and the same catalog/registry system. The only difference is whether the AI is allowed to include prose alongside the patches. ## Next - Learn about the [JSONL streaming format](/docs/streaming) - See the [AI SDK integration](/docs/ai-sdk) for setup with the Vercel AI SDK ================================================ FILE: apps/web/app/(main)/docs/installation/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/installation") # Installation Install the core package plus your renderer of choice. ## For React UI Peer dependencies: `react ^19.0.0` and `zod ^4.0.0`. ## For Vue Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`. ## For Svelte Peer dependencies: `svelte ^5.0.0` and `zod ^4.0.0`. ## For React UI with shadcn/ui Pre-built components for fast prototyping and production use: Requires Tailwind CSS in your project. See the [@json-render/shadcn API reference](/docs/api/shadcn) for usage. ## For React Native ## For Remotion Video ## For React Email ## For External State Management (Optional) If you want to wire json-render to an existing state management library instead of the built-in store, install the adapter for your library: See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage. ## For AI Integration To use json-render with AI models, you'll also need the Vercel AI SDK: ================================================ FILE: apps/web/app/(main)/docs/layout.tsx ================================================ import { DocsMobileNav } from "@/components/docs-mobile-nav"; import { DocsSidebar } from "@/components/docs-sidebar"; import { CopyPageButton } from "@/components/copy-page-button"; import { TableOfContents } from "@/components/table-of-contents"; export default function DocsLayout({ children, }: { children: React.ReactNode; }) { return ( <>
{/* Sidebar */} {/* Content */}
{children}
{/* On this page */}
); } ================================================ FILE: apps/web/app/(main)/docs/migration/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/migration") # Migration Guide This guide covers breaking changes introduced in v0.6.0 and how to update your code. ## State Provider `DataProvider` has been renamed to `StateProvider`, and its props have changed. **Before:** ```tsx import { DataProvider } from "@json-render/react"; {children} ``` **After:** ```tsx import { StateProvider } from "@json-render/react"; console.log(path, value)}> {children} ``` `StateProvider` now manages state internally. Use `useStateStore()` to access `get`, `set`, and `update`.
Before After
DataProviderStateProvider
data propinitialState prop
getValue / setValue propsRemoved (use useStateStore() hook for get / set)
useDatauseStateStore
useDataValueuseStateValue
useDataBindinguseStateBinding (deprecated, use useBoundProp instead)
DataModel typeStateModel type
## Dynamic Expressions All dynamic value expressions have been renamed to use `$state`, `$item`, and `$index`. **Before:** ```json { "type": "Text", "props": { "label": { "$path": "/user/name" }, "count": { "$data": "/items/length" } } } ``` **After:** ```json { "type": "Text", "props": { "label": { "$state": "/user/name" }, "count": { "$state": "/items/length" } } } ``` Inside repeat scopes, use `$item` and `$index`: ```json { "type": "Card", "props": { "title": { "$item": "name" }, "subtitle": { "$index": true } } } ```
Before After
{'{ "$path": "/..." }'}{'{ "$state": "/..." }'}
{'{ "$data": "/..." }'}{'{ "$state": "/..." }'}
## Two-Way Binding Form components no longer use `valuePath` / `statePath` props. Instead, use `$bindState` expressions on the value prop, and `useBoundProp` in your registry. **Before (catalog):** ```typescript Input: { props: z.object({ label: z.string(), valuePath: z.string(), placeholder: z.string().optional(), }), } ``` **Before (spec):** ```json { "type": "Input", "props": { "label": "Email", "valuePath": "/form/email" } } ``` **Before (registry):** ```tsx Input: ({ props }) => { const [value, setValue] = useStateBinding(props.valuePath); return setValue(e.target.value)} />; } ``` **After (catalog):** ```typescript Input: { props: z.object({ label: z.string(), value: z.string().optional(), placeholder: z.string().optional(), }), } ``` **After (spec):** ```json { "type": "Input", "props": { "label": "Email", "value": { "$bindState": "/form/email" } } } ``` **After (registry):** ```tsx Input: ({ props, bindings }) => { const [value, setValue] = useBoundProp(props.value, bindings?.value); return setValue(e.target.value)} />; } ``` `$bindState` reads from and writes to the given state path. Inside repeat scopes, use `$bindItem` to bind to a field on the current item: ```json { "type": "Checkbox", "props": { "checked": { "$bindItem": "completed" } } } ``` ## Visibility Conditions Visibility conditions have been renamed to use `$state`, `$and`, and `$or`. **Before:** ```json { "path": "/isAdmin" } { "eq": [{ "path": "/role" }, "admin"] } { "and": [{ "path": "/isAdmin" }, { "path": "/feature" }] } { "or": [{ "path": "/roleA" }, { "path": "/roleB" }] } ``` **After:** ```json { "$state": "/isAdmin" } { "$state": "/role", "eq": "admin" } { "$and": [{ "$state": "/isAdmin" }, { "$state": "/feature" }] } { "$or": [{ "$state": "/roleA" }, { "$state": "/roleB" }] } ``` You can also use an array as shorthand for `$and`: ```json [{ "$state": "/isAdmin" }, { "$state": "/feature" }] ``` Inside repeat scopes, use `$item` and `$index`: ```json { "$item": "isActive" } { "$index": true, "eq": 0 } ``` ## Event System Components now use `emit` to fire named events. `onAction` has been removed. **Before:** ```tsx Button: ({ props, onAction }) => ( ) ``` **After:** ```tsx Button: ({ props, emit }) => ( ) ``` `emit` is always defined (never `undefined`), so optional chaining is not needed. ## Actions Context `dispatch` has been renamed to `execute`, and the provider prop has been renamed from `actionHandlers` to `handlers`. **Before:** ```tsx const { dispatch } = useActions(); dispatch({ action: "submit", params: {} }); ``` **After:** ```tsx const { execute } = useActions(); execute({ action: "submit", params: {} }); ``` ## Repeat / List Rendering The `repeat` field now uses `statePath` instead of `path`. **Before:** ```json { "type": "Column", "repeat": { "path": "/todos", "key": "id" }, "children": ["todo-item"] } ``` **After:** ```json { "type": "Column", "repeat": { "statePath": "/todos", "key": "id" }, "children": ["todo-item"] } ``` ## Catalog Creation `createCatalog` and `generateSystemPrompt` have been replaced by `defineSchema` + `defineCatalog`. **Before:** ```typescript import { createCatalog, generateSystemPrompt } from "@json-render/core"; const catalog = createCatalog({ name: "my-app", components: { /* ... */ }, actions: { /* ... */ }, }); const prompt = generateSystemPrompt(catalog); ``` **After:** ```typescript import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; const catalog = defineCatalog(schema, { components: { /* ... */ }, actions: { /* ... */ }, }); const prompt = catalog.prompt(); // Inline mode prompt (formerly "chat") const inlinePrompt = catalog.prompt({ mode: "inline" }); ``` ## Generation Modes The generation mode values passed to `catalog.prompt()` have been renamed for clarity: - `"generate"` is now `"standalone"` - `"chat"` is now `"inline"` The old names are accepted as deprecated aliases, so existing code will continue to work. Update when convenient. **Before:** ```typescript const prompt = catalog.prompt({ mode: "generate" }); const chatPrompt = catalog.prompt({ mode: "chat" }); ``` **After:** ```typescript const prompt = catalog.prompt({ mode: "standalone" }); const inlinePrompt = catalog.prompt({ mode: "inline" }); ``` The default mode (when no `mode` option is provided) is `"standalone"`, which behaves identically to the previous `"generate"` default. ## Validation `ValidationCheck` now uses `type` instead of `fn`, `ValidationProvider` uses `customFunctions` instead of `functions`, and `useFieldValidation` takes a config object instead of a checks array. **Before:** ```json { "fn": "required", "message": "Required" } { "fn": "minLength", "args": { "length": 8 }, "message": "Too short" } ``` **After:** ```json { "type": "required", "message": "Required" } { "type": "minLength", "args": { "min": 8 }, "message": "Too short" } ```
Before After
{'{ fn: "required" }'}{'{ type: "required" }'}
{'ValidationProvider functions={...}'}{'ValidationProvider customFunctions={...}'}
useFieldValidation(path, checks)useFieldValidation(path, config) where config is {'{ checks, validateOn? }'}
## Visibility Provider The `auth` prop has been removed from `VisibilityProvider`. Auth state should be modeled as regular state. **Before:** ```tsx ``` ```json { "auth": "signedIn" } ``` **After:** ```tsx ``` ```json { "$state": "/auth/isSignedIn" } ``` ## Codegen `traverseTree` has been renamed to `traverseSpec`, `SpecVisitor` to `TreeVisitor`, and the visitor callback now receives a `key` parameter. **Before:** ```typescript import { traverseTree } from "@json-render/codegen"; traverseTree(tree, (element) => { // ... }); ``` **After:** ```typescript import { traverseSpec } from "@json-render/codegen"; traverseSpec(spec, (element, key) => { // ... }); ``` ## Action Params Action params in specs now use `statePath` instead of `path`. **Before:** ```json { "on": { "press": { "action": "setState", "params": { "path": "/count", "value": 0 } } } } ``` **After:** ```json { "on": { "press": { "action": "setState", "params": { "statePath": "/count", "value": 0 } } } } ``` ## Removed Exports The following exports have been removed from `@json-render/core`:
Removed Replacement
createCatalog defineCatalog(schema, config)
generateCatalogPrompt catalog.prompt()
generateSystemPrompt catalog.prompt()
ComponentDefinition Use catalog component config directly
CatalogConfig Use defineCatalog parameters
SystemPromptOptions Use PromptOptions
LogicExpression Use VisibilityCondition
AuthState Model auth as regular state (e.g. /auth/isSignedIn)
evaluateLogicExpression Use evaluateVisibility
createRendererFromCatalog Use defineRegistry
traverseTree (codegen) Use traverseSpec
================================================ FILE: apps/web/app/(main)/docs/openapi/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/openapi") # OpenAPI Integration Use json-render to generate dynamic forms and UIs from [OpenAPI/Swagger](https://swagger.io/specification/) schemas.

Concept: This page demonstrates how json-render can support OpenAPI schemas. The examples are illustrative and may require adaptation for production use.

## Why OpenAPI? OpenAPI specifications describe your API's endpoints, request bodies, and response schemas. By converting OpenAPI schemas to json-render specs, you can: - Automatically generate forms for API endpoints - Display API responses with type-aware rendering - Keep your UI in sync with your API schema - Let AI generate UIs that match your API contracts ## Example OpenAPI Schema A typical OpenAPI schema for a request body: ```json { "openapi": "3.0.0", "paths": { "/users": { "post": { "summary": "Create a new user", "operationId": "createUser", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateUserRequest" } } } } } } }, "components": { "schemas": { "CreateUserRequest": { "type": "object", "required": ["email", "name"], "properties": { "name": { "type": "string", "description": "User's full name", "minLength": 1, "maxLength": 100 }, "email": { "type": "string", "format": "email", "description": "User's email address" }, "age": { "type": "integer", "minimum": 0, "maximum": 150, "description": "User's age" }, "role": { "type": "string", "enum": ["admin", "user", "guest"], "default": "user", "description": "User's role" }, "preferences": { "type": "object", "properties": { "newsletter": { "type": "boolean", "default": false }, "theme": { "type": "string", "enum": ["light", "dark", "system"] } } } } } } } } ``` ## Define an OpenAPI-to-UI Catalog Create components that map to OpenAPI data types: ```typescript import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; import { z } from 'zod'; export const openapiCatalog = defineCatalog(schema, { components: { Form: { description: 'API form container', props: z.object({ operationId: z.string(), endpoint: z.string(), method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), title: z.string().optional(), description: z.string().optional(), }), }, StringField: { description: 'String input field', props: z.object({ name: z.string(), label: z.string(), description: z.string().optional(), required: z.boolean().optional(), format: z.enum(['text', 'email', 'uri', 'uuid', 'date', 'date-time', 'password']).optional(), minLength: z.number().optional(), maxLength: z.number().optional(), pattern: z.string().optional(), placeholder: z.string().optional(), defaultValue: z.string().optional(), }), }, NumberField: { description: 'Number input field', props: z.object({ name: z.string(), label: z.string(), description: z.string().optional(), required: z.boolean().optional(), type: z.enum(['integer', 'number']).optional(), minimum: z.number().optional(), maximum: z.number().optional(), defaultValue: z.number().optional(), }), }, BooleanField: { description: 'Boolean toggle field', props: z.object({ name: z.string(), label: z.string(), description: z.string().optional(), defaultValue: z.boolean().optional(), }), }, EnumField: { description: 'Enum selection field', props: z.object({ name: z.string(), label: z.string(), description: z.string().optional(), required: z.boolean().optional(), options: z.array(z.object({ value: z.string(), label: z.string().optional(), })), defaultValue: z.string().optional(), }), }, ObjectField: { description: 'Nested object group', props: z.object({ name: z.string(), label: z.string(), description: z.string().optional(), collapsible: z.boolean().optional(), }), }, }, actions: { submit: { description: 'Submit form to API endpoint', params: z.object({ operationId: z.string() }), }, reset: { description: 'Reset form to defaults', params: z.object({}), }, }, }); ``` ## Convert OpenAPI Schema to Spec Transform OpenAPI schemas into json-render specs by recursively walking the schema properties and mapping each type to the corresponding catalog component. The converter handles nested objects, enums, arrays, and all primitive types. ## Usage Example ```tsx 'use client'; import { OpenAPIForm } from './openapi-form'; import { operationToSpec } from './openapi-to-spec'; // Your OpenAPI schema (typically loaded from your API) const createUserSchema = { type: 'object', required: ['email', 'name'], properties: { name: { type: 'string', description: "User's full name" }, email: { type: 'string', format: 'email', description: "User's email" }, age: { type: 'integer', minimum: 0, maximum: 150 }, role: { type: 'string', enum: ['admin', 'user', 'guest'], default: 'user' }, }, }; // Convert to spec const spec = operationToSpec( 'createUser', 'POST', '/api/users', createUserSchema, 'Create User', 'Add a new user to the system', ); export function CreateUserForm() { const handleSubmit = async (data: Record) => { const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (response.ok) { console.log('User created!'); } }; return ; } ``` ## Auto-generating from OpenAPI Document Load and parse an OpenAPI document to generate forms for all operations: ```typescript import SwaggerParser from '@apidevtools/swagger-parser'; import { operationToSpec } from './openapi-to-spec'; export async function loadOpenAPISpecs(specUrl: string) { const api = await SwaggerParser.dereference(specUrl); const specs: Record = {}; for (const [path, methods] of Object.entries(api.paths)) { for (const [method, operation] of Object.entries(methods)) { if (!operation.requestBody?.content?.['application/json']?.schema) continue; const schema = operation.requestBody.content['application/json'].schema; const operationId = operation.operationId || `${method}_${path.replace(/\//g, '_')}`; specs[operationId] = operationToSpec( operationId, method, path, schema, operation.summary, operation.description, ); } } return specs; } // Usage const specs = await loadOpenAPISpecs('https://api.example.com/openapi.json'); // specs.createUser, specs.updateUser, etc. ``` ## Next Learn about [streaming](/docs/streaming) for progressive UI rendering. ================================================ FILE: apps/web/app/(main)/docs/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs") # Introduction json-render is a framework for **Generative UI** — AI-generated interfaces that are safe, predictable, and render natively on any platform. ## What is Generative UI? Most AI integrations treat the interface as fixed. Developers build layouts ahead of time, and AI fills in the data — a chatbot response, a summary, a recommendation. The UI itself never changes. **Generative UI is different.** The AI generates the interface itself: which components to show, how to arrange them, what data to bind, what actions to wire up. Every response can produce a unique, purpose-built UI tailored to the user's request. The challenge is that unconstrained AI output is unpredictable. It can hallucinate component names, produce invalid structures, or generate unsafe code. You need a way to let AI be creative with layout and composition while keeping it within boundaries you control. That is what json-render does. You define a **catalog** of components and actions. AI generates JSON constrained to that catalog. Your components render the result natively — on web or mobile — with full type safety and no arbitrary code execution. ## How json-render Works ### 1. Define your catalog A catalog declares what AI can use: components with typed props, actions with typed params. ```typescript import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; import { z } from 'zod'; export const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string() }), slots: ["default"], }, Metric: { props: z.object({ label: z.string(), value: z.string(), }), }, }, }); ``` ### 2. AI generates a spec Given a prompt like "show me a revenue dashboard", AI outputs a JSON spec — a flat tree of elements constrained to your catalog: ```json { "root": "card-1", "elements": { "card-1": { "type": "Card", "props": { "title": "Revenue Dashboard" }, "children": ["metric-1", "metric-2"] }, "metric-1": { "type": "Metric", "props": { "label": "Total Revenue", "value": "$48,200" } }, "metric-2": { "type": "Metric", "props": { "label": "Growth", "value": "+12%" } } } } ``` ### 3. Your components render it Map catalog types to real components with a registry, then render the spec: ```tsx import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react'; ``` The result is a native UI built from your own components — not an iframe, not markdown, not generated code. The AI chose the structure; you control everything else. ## Key Concepts - **[Catalog](/docs/catalog)** — Define the components, actions, and validation functions AI can use. This is the contract between your app and the AI. - **[Registry](/docs/registry)** — Map catalog types to platform-specific implementations. React components on web, React Native views on mobile. - **[Specs](/docs/specs)** — The JSON output AI generates. A flat tree of typed elements with props, children, data bindings, and visibility conditions. - **[Streaming](/docs/streaming)** — Render progressively as the AI responds. Each JSONL patch adds to the spec and the UI updates in real time. - **[Data Binding](/docs/data-binding)** — Bind props to runtime data with `$state` paths, repeat elements over arrays, and wire two-way input bindings. - **[Visibility](/docs/visibility)** — Show or hide elements based on state conditions. The AI can generate conditional UIs without writing logic. - **[Generation Modes](/docs/generation-modes)** — Standalone mode for full-page generated UI or inline mode for UI embedded in a conversation. ## Next - [Installation](/docs/installation) — Add json-render to your project - [Quick Start](/docs/quick-start) — Build your first generative UI in 5 minutes ================================================ FILE: apps/web/app/(main)/docs/quick-start/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/quick-start") # Quick Start Get up and running with json-render in 5 minutes. ## 1. Define your catalog Create a catalog that defines what components AI can use: ```typescript // lib/catalog.ts import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; import { z } from 'zod'; export const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string(), description: z.string().nullable(), }), slots: ["default"], description: "Container card with optional title", }, Button: { props: z.object({ label: z.string(), action: z.string().nullable(), }), description: "Clickable button that triggers an action", }, Text: { props: z.object({ content: z.string(), }), description: "Text paragraph", }, }, actions: { submit: { params: z.object({ formId: z.string() }), description: "Submit a form", }, navigate: { params: z.object({ url: z.string() }), description: "Navigate to a URL", }, }, }); ``` ## 2. Define your components Use `defineRegistry` to map catalog types to React components. Each component receives type-safe `props`, `children`, and `emit`: ```tsx // lib/registry.tsx import { defineRegistry } from '@json-render/react'; import { catalog } from './catalog'; export const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => (

{props.title}

{props.description && (

{props.description}

)} {children}
), Button: ({ props, emit }) => ( ), Text: ({ props }) => (

{props.content}

), }, }); ``` ## 3. Create an API route Set up a streaming API route for AI generation: ```typescript // app/api/generate/route.ts import { streamText } from 'ai'; import { catalog } from '@/lib/catalog'; export async function POST(req: Request) { const { prompt } = await req.json(); // Generate system prompt from catalog const systemPrompt = catalog.prompt(); const result = streamText({ model: 'anthropic/claude-haiku-4.5', system: systemPrompt, prompt, }); return result.toTextStreamResponse(); } ``` ## 4. Render the UI Use providers and the `Renderer` with your registry to display AI-generated UI: ```tsx // app/page.tsx 'use client'; import { Renderer, StateProvider, ActionProvider, VisibilityProvider, ValidationProvider, useUIStream } from '@json-render/react'; import { registry } from '@/lib/registry'; export default function Page() { const { spec, isStreaming, send } = useUIStream({ api: '/api/generate', }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); send(formData.get('prompt') as string); }; return ( console.log('Submit:', params), navigate: (params) => console.log('Navigate:', params), }}>
); } ``` ## Quick Start with shadcn/ui If you want to skip defining components from scratch, use `@json-render/shadcn` for 36 pre-built components: ```typescript // lib/catalog.ts import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog'; export const catalog = defineCatalog(schema, { components: { Card: shadcnComponentDefinitions.Card, Stack: shadcnComponentDefinitions.Stack, Heading: shadcnComponentDefinitions.Heading, Button: shadcnComponentDefinitions.Button, Input: shadcnComponentDefinitions.Input, }, actions: {}, }); ``` ```tsx // lib/registry.tsx import { defineRegistry } from '@json-render/react'; import { shadcnComponents } from '@json-render/shadcn'; import { catalog } from './catalog'; export const { registry } = defineRegistry(catalog, { components: { Card: shadcnComponents.Card, Stack: shadcnComponents.Stack, Heading: shadcnComponents.Heading, Button: shadcnComponents.Button, Input: shadcnComponents.Input, }, }); ``` See the [@json-render/shadcn API reference](/docs/api/shadcn) for the full component list. ## Next steps - Learn about [catalogs](/docs/catalog) in depth - Explore [data binding](/docs/data-binding) for dynamic values - Add [action handlers](/docs/registry#action-handlers) for interactivity - Implement [conditional visibility](/docs/visibility) - Use [pre-built shadcn/ui components](/docs/api/shadcn) for fast prototyping ================================================ FILE: apps/web/app/(main)/docs/registry/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/registry") # Registry A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines *what* AI can generate — the registry provides the *how*. What a registry contains depends on the schema you use. Each package defines its own schema, which determines the shape of both the catalog and the registry. - **`@json-render/react`** — Components (React elements) and action handlers - **`@json-render/react-native`** — Components (React Native elements) and action handlers - **`@json-render/react-email`** — Email components (React Email / HTML) - **`@json-render/remotion`** — Clip components, transitions, and effects ## @json-render/react ### defineRegistry Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both: ```tsx import { defineRegistry } from '@json-render/react'; import { myCatalog } from './catalog'; export const { registry, handlers, executeAction } = defineRegistry(myCatalog, { components: { Card: ({ props, children }) => (

{props.title}

{props.description &&

{props.description}

} {children}
), Button: ({ props, emit }) => ( ), }, actions: { submit_form: async (params, setState) => { const res = await fetch('/api/submit', { method: 'POST', body: JSON.stringify(params), }); const result = await res.json(); setState((prev) => ({ ...prev, formResult: result })); }, export_data: async (params) => { const blob = await generateExport(params.format); downloadBlob(blob, `export.${params.format}`); }, }, }); ``` The returned object contains: - `registry` — component registry for `` - `handlers` — factory for ActionProvider-compatible handlers - `executeAction` — imperative action dispatch (for use outside the React tree) ### Component Props Each component receives a `ComponentContext` object: ```typescript interface ComponentContext { props: T; // Type-safe props from your catalog children?: React.ReactNode; // Rendered children (for slot components) emit: (event: string) => void; // Emit a named event (always defined) on: (event: string) => EventHandle; // Get event handle with metadata loading?: boolean; // Whether the renderer is in a loading state bindings?: Record; // State paths from $bindState/$bindItem expressions } interface EventHandle { emit: () => void; // Fire the event shouldPreventDefault: boolean; // Whether any binding requested preventDefault bound: boolean; // Whether any handler is bound } ``` Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way. Use `emit("press")` for simple event firing. Use `on("click")` when you need to inspect event metadata: ```tsx Link: ({ props, on }) => { const click = on("click"); return ( { if (click.shouldPreventDefault) e.preventDefault(); click.emit(); }} > {props.label} ); }, ``` #### Using `bindings` for two-way binding When a spec uses `{ "$bindState": "/path" }` or `{ "$bindItem": "field" }` on a prop, the renderer resolves the **value** into `props` and provides the **write-back path** in `bindings`. Use the `useBoundProp` hook to wire both together: ```tsx import { useBoundProp, defineRegistry } from '@json-render/react'; // Inside your registry: TextInput: ({ props, bindings }) => { const [value, setValue] = useBoundProp(props.value, bindings?.value); return ( setValue(e.target.value)} /> ); }, ``` `useBoundProp` returns `[resolvedValue, setter]`. The setter writes to the bound state path. If no binding exists (the prop is a literal), the setter is a no-op. ### Action Handlers Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail. Actions are declared in your [catalog](/docs/catalog). The `@json-render/react` schema supports an `actions` key where you define what operations AI can trigger: ```typescript import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; import { z } from 'zod'; const catalog = defineCatalog(schema, { components: { /* ... */ }, actions: { submit_form: { params: z.object({ formId: z.string(), }), description: 'Submit a form', }, export_data: { params: z.object({ format: z.enum(['csv', 'pdf', 'json']), }), }, navigate: { params: z.object({ url: z.string(), }), }, }, }); ``` Action handlers receive `(params, setState, state)` and are defined inside `defineRegistry`: ```tsx export const { handlers, executeAction } = defineRegistry(catalog, { actions: { submit_form: async (params, setState) => { const response = await fetch('/api/submit', { method: 'POST', body: JSON.stringify({ formId: params.formId }), }); const result = await response.json(); setState((prev) => ({ ...prev, formResult: result })); }, export_data: async (params) => { const blob = await generateExport(params.format); downloadBlob(blob, `export.${params.format}`); }, navigate: (params) => { window.location.href = params.url; }, }, }); ``` ### Data Binding Most data binding is handled automatically by the renderer — `$state`, `$item`, and `$index` expressions in props are resolved before your component receives them. See the [Data Binding](/docs/data-binding) guide for the full reference. For two-way binding (form inputs), use `{ "$bindState": "/path" }` on the natural value prop (or `{ "$bindItem": "field" }` inside repeat scopes). The renderer provides a `bindings` map with the state path for each bound prop. Use `useBoundProp` to get `[value, setValue]`: ```tsx import { useBoundProp } from '@json-render/react'; // Inside defineRegistry components: Input: ({ props, bindings }) => { const [value, setValue] = useBoundProp( props.value, bindings?.value ); return ( setValue(e.target.value)} placeholder={props.placeholder} /> ); }, ``` For read-only state access (e.g. displaying a value from state), use `$state` expressions in props — they are resolved before the component receives them. For custom logic, use `useStateStore` and `getByPath` from `@json-render/core`. ### Using the Renderer Wire everything together with providers and the `` component: ```tsx import { useMemo, useRef } from 'react'; import { Renderer, StateProvider, VisibilityProvider, ActionProvider, } from '@json-render/react'; import { registry, handlers } from './registry'; function App({ spec, state, setState }) { const stateRef = useRef(state); const setStateRef = useRef(setState); stateRef.current = state; setStateRef.current = setState; const actionHandlers = useMemo( () => handlers(() => setStateRef.current, () => stateRef.current), [], ); return ( ); } ``` ## @json-render/react-native `@json-render/react-native` uses the same `defineRegistry` API. The only difference is that components return React Native elements instead of HTML: ```tsx import { defineRegistry } from '@json-render/react-native'; import { View, Text, Pressable } from 'react-native'; export const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => ( {props.title} {children} ), Button: ({ props, emit }) => ( emit("press")}> {props.label} ), }, }); ``` See the [@json-render/react-native API reference](/docs/api/react-native) for the full API. ## @json-render/react-email `@json-render/react-email` uses `defineRegistry` like React and React Native. Components render to React Email primitives (`@react-email/components`). Use `renderToHtml` or `renderToPlainText` for server-side email output: ```tsx import { defineRegistry } from '@json-render/react-email'; import { renderToHtml } from '@json-render/react-email'; import { Body, Container, Heading, Text } from '@react-email/components'; export const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => ( {props.title} {children} ), }, }); const html = await renderToHtml(spec, { registry }); ``` See the [@json-render/react-email API reference](/docs/api/react-email) for the full API. ## @json-render/remotion `@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production: ```tsx import { Renderer, standardComponents } from '@json-render/remotion'; // Use the standard components directly // Or extend with your own const components = { ...standardComponents, CustomSlide: ({ clip }) => {/* ... */}, }; ``` The Remotion schema also supports `transitions` and `effects` in the catalog rather than actions. See the [@json-render/remotion API reference](/docs/api/remotion) for the full API. ## Next Learn about [data binding](/docs/data-binding) for dynamic values. ================================================ FILE: apps/web/app/(main)/docs/renderers/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata"; export const metadata = pageMetadata("docs/renderers"); # Renderers json-render supports multiple output targets. Each renderer takes the same core concept -- a JSON spec constrained to a catalog -- and renders it natively on a different platform or into a different format. All renderers share the same workflow: 1. Define a catalog with `defineCatalog` 2. AI generates a JSON spec 3. The renderer turns the spec into platform-native output
Renderer Package Output
React @json-render/react React component tree
Vue @json-render/vue Vue 3 component tree
Svelte @json-render/svelte Svelte 5 component tree
Solid @json-render/solid SolidJS component tree
shadcn/ui @json-render/shadcn Pre-built Radix UI + Tailwind components (uses React renderer)
React Native @json-render/react-native Native mobile views
Image @json-render/image SVG / PNG (via Satori)
React PDF @json-render/react-pdf PDF documents
Remotion @json-render/remotion Video compositions
## React Render specs as React component trees in the browser. Supports data binding, streaming, actions, validation, visibility, and computed values. ```tsx import { defineRegistry, Renderer } from "@json-render/react"; import { schema } from "@json-render/react/schema"; const { registry } = defineRegistry(catalog, { components }); ; ``` Use `StateProvider`, `VisibilityProvider`, and `ActionProvider` for full interactivity. See the [@json-render/react API reference](/docs/api/react) for details. ## Vue Vue 3 renderer with full feature parity with React: data binding, visibility, actions, validation, repeat scopes, and streaming. ```typescript import { defineRegistry, Renderer } from "@json-render/vue"; import { schema } from "@json-render/vue/schema"; const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => h("div", { class: "card" }, [h("h3", null, props.title), children]), }, }); ``` Uses composables (`useStateStore`, `useStateBinding`, `useActions`, etc.) instead of React hooks. See the [@json-render/vue API reference](/docs/api/vue) for details. ## Svelte Svelte 5 renderer with runes-compatible context helpers, visibility conditions, actions, and streaming support. ```typescript import { defineRegistry, Renderer } from "@json-render/svelte"; import { schema } from "@json-render/svelte/schema"; const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => /* Svelte snippet */, }, }); ``` See the [@json-render/svelte API reference](/docs/api/svelte) for details. ## Solid SolidJS renderer with fine-grained reactivity, state bindings, validation, visibility, and event-driven actions. ```tsx import { defineRegistry, Renderer } from "@json-render/solid"; import { schema } from "@json-render/solid/schema"; const { registry } = defineRegistry(catalog, { components: { Card: (renderProps) =>
{renderProps.children}
, }, }); ; ``` See the [@json-render/solid API reference](/docs/api/solid) for details. ## shadcn/ui 36 pre-built components using Radix UI and Tailwind CSS. Built on top of `@json-render/react` -- no custom renderer needed. ```tsx import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { defineRegistry, Renderer } from "@json-render/react"; import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog"; import { shadcnComponents } from "@json-render/shadcn"; const catalog = defineCatalog(schema, { components: { Card: shadcnComponentDefinitions.Card, Button: shadcnComponentDefinitions.Button, }, }); const { registry } = defineRegistry(catalog, { components: { Card: shadcnComponents.Card, Button: shadcnComponents.Button, }, }); ``` See the [@json-render/shadcn API reference](/docs/api/shadcn) for the full component list. ## React Native Render specs as native mobile views. Includes 25+ standard components and standard action definitions. ```tsx import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react-native/schema"; import { standardComponentDefinitions, standardActionDefinitions, } from "@json-render/react-native/catalog"; import { defineRegistry, Renderer } from "@json-render/react-native"; const catalog = defineCatalog(schema, { components: { ...standardComponentDefinitions }, actions: standardActionDefinitions, }); const { registry } = defineRegistry(catalog, { components: {} }); ; ``` See the [@json-render/react-native API reference](/docs/api/react-native) for details. ## Image Generate SVG and PNG images from JSON specs using Satori. Ideal for OG images, social cards, and banners. ```typescript import { renderToSvg, renderToPng } from "@json-render/image/render"; const svg = await renderToSvg(spec, { fonts }); const png = await renderToPng(spec, { fonts }); ``` Nine standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer. PNG output requires `@resvg/resvg-js` as an optional peer dependency. See the [@json-render/image API reference](/docs/api/image) for details. ## React PDF Generate PDF documents from JSON specs using `@react-pdf/renderer`. Render to buffer, stream, or file. ```typescript import { renderToBuffer, renderToStream, renderToFile, } from "@json-render/react-pdf"; const buffer = await renderToBuffer(spec); const stream = await renderToStream(spec); await renderToFile(spec, "./output.pdf"); ``` Standard components include Document, Page, View, Row, Column, Heading, Text, Image, Table, List, Divider, Spacer, Link, and PageNumber. See the [@json-render/react-pdf API reference](/docs/api/react-pdf) for details. ## Remotion Turn JSON timeline specs into video compositions with Remotion. ```tsx import { Player } from "@remotion/player"; import { Renderer } from "@json-render/remotion"; ; ``` Uses a timeline spec format with compositions, tracks, and clips. Includes standard components (TitleCard, TypingText, ImageSlide, etc.), transitions (fade, slide, zoom, wipe), and effects. See the [@json-render/remotion API reference](/docs/api/remotion) for details. ## Custom Renderers You can build your own renderer for any output target. See the [Custom Schema & Renderer](/docs/custom-schema) guide for how to define a custom schema and wire it to your own rendering logic. ================================================ FILE: apps/web/app/(main)/docs/schemas/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/schemas") # Schemas Schemas define the structure and validation rules for your UI specs. ## What is a Schema? A schema defines the JSON structure that describes your UI. It includes: - **Element structure** — How components are nested and referenced - **Property types** — What props each component accepts - **Data binding syntax** — How to reference dynamic data - **Action format** — How user interactions are defined ## Schema-Agnostic by Design json-render can work with any JSON schema. `@json-render/core` provides the primitives to define catalogs and renderers for any format: - **@json-render/react** — The built-in flat element tree schema - **[A2UI](/docs/a2ui)** — Google's Agent-to-User Interaction protocol - **[Adaptive Cards](/docs/adaptive-cards)** — Microsoft's platform-agnostic UI format - **AG-UI** — CopilotKit's Agent User Interaction Protocol - **OpenAPI/Swagger** — API documentation schemas for dynamic forms - **Custom schemas** — Design your own format tailored to your domain See the [Custom Schema guide](/docs/custom-schema) to learn how to implement support for any schema. ## Built-in Schema `@json-render/react` uses a flat element tree schema with a root key and elements map: ```json { "root": "card-1", "elements": { "card-1": { "type": "Card", "props": { "title": "Dashboard" }, "children": ["text-1", "button-1"] }, "text-1": { "type": "Text", "props": { "content": { "$state": "/user/name" } }, "children": [] }, "button-1": { "type": "Button", "props": { "label": "Click me" }, "children": [] } } } ``` ## Schema Components ### Element Structure In the built-in schema, each element in the elements map has this structure: ```typescript interface Element { type: string; // Component type from catalog props: Record; // Component properties children: string[]; // Array of child element keys visible?: VisibilityCondition; // Conditional display } ``` ### Data Binding Syntax Reference dynamic data using `$state` expressions in props. The value is a JSON Pointer path into the state model: ```json { "type": "Text", "props": { "content": { "$state": "/user/name" }, "count": { "$state": "/items/count" } }, "children": [] } ``` json-render also supports `$item` and `$index` expressions for lists, two-way binding via `$bindState` / `$bindItem`, and conditional props. See [Data Binding](/docs/data-binding) for the full reference. ### Action Format Actions are defined in the catalog and referenced from components. The renderer handles action execution: ```typescript // In your catalog actions: { navigate: { params: z.object({ url: z.string() }), description: 'Navigate to a URL', }, apiCall: { params: z.object({ endpoint: z.string(), method: z.enum(['GET', 'POST', 'PUT', 'DELETE']), }), description: 'Make an API request', }, } ``` ## Custom Schemas `@json-render/core` is schema-agnostic. You can define any JSON structure: ```typescript import { z } from 'zod'; // Define your own element schema const MyElementSchema = z.object({ component: z.string(), settings: z.record(z.unknown()), nested: z.array(z.lazy(() => MyElementSchema)).optional(), }); // Define your own data binding format const BoundValue = z.object({ literal: z.string().optional(), source: z.string().optional(), // e.g., "/users/0/name" }); // Define your own action format const ActionSchema = z.object({ name: z.string(), context: z.record(z.unknown()).optional(), }); ``` ## Schema vs Catalog The schema and catalog work together but serve different purposes: - **Schema** — Defines the JSON structure (how elements are organized) - **Catalog** — Defines available components and their props (what can be used) The schema is the grammar; the catalog is the vocabulary. ## Next Learn about [specs](/docs/specs) — the actual JSON documents that describe your UI. ================================================ FILE: apps/web/app/(main)/docs/skills/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata"; export const metadata = pageMetadata("docs/skills"); # Skills json-render ships with skills that teach AI coding agents how to use each package. Install a skill and your agent in Cursor, Claude Code, or Codex can generate json-render UIs without manual guidance. ## Available Skills - **core** — Core schemas, catalogs, and AI prompt generation. - **react** — React renderer that turns JSON specs into React component trees. - **react-pdf** — PDF renderer using `@react-pdf/renderer`. - **react-email** — Email renderer that produces HTML or plain-text emails. - **react-native** — React Native renderer for native mobile UIs. - **shadcn** — Pre-built shadcn/ui components (Radix UI + Tailwind). - **image** — Image renderer that turns JSON specs into SVG and PNG via Satori. - **remotion** — Remotion renderer for video generation from JSON timeline specs. - **vue** — Vue 3 renderer for Vue component trees. - **svelte** — Svelte 5 renderer for Svelte component trees. - **solid** — SolidJS renderer for fine-grained reactive component trees. - **codegen** — Code generation utilities for building custom exporters. - **mcp** — MCP Apps integration for Claude, ChatGPT, Cursor, and VS Code. - **redux** — Redux adapter for json-render's `StateStore` interface. - **zustand** — Zustand adapter for json-render's `StateStore` interface. - **jotai** — Jotai adapter for json-render's `StateStore` interface. - **xstate** — XState Store adapter for json-render's `StateStore` interface. ## Installation ```bash npx skills add vercel-labs/json-render --skill core npx skills add vercel-labs/json-render --skill react npx skills add vercel-labs/json-render --skill react-pdf npx skills add vercel-labs/json-render --skill react-email npx skills add vercel-labs/json-render --skill react-native npx skills add vercel-labs/json-render --skill shadcn npx skills add vercel-labs/json-render --skill image npx skills add vercel-labs/json-render --skill remotion npx skills add vercel-labs/json-render --skill vue npx skills add vercel-labs/json-render --skill svelte npx skills add vercel-labs/json-render --skill solid npx skills add vercel-labs/json-render --skill codegen npx skills add vercel-labs/json-render --skill mcp npx skills add vercel-labs/json-render --skill redux npx skills add vercel-labs/json-render --skill zustand npx skills add vercel-labs/json-render --skill jotai npx skills add vercel-labs/json-render --skill xstate ``` After installing, your AI agent will automatically activate the right skill when it encounters a matching request. ## core The foundational skill. Teaches agents how to define catalogs, create schemas, build specs, and generate AI prompts. This is the starting point for any json-render project and covers `defineCatalog`, `defineSchema`, `specSchema`, `toPrompt`, and the full spec format. ## react Teaches agents how to render JSON specs as React component trees using `JsonRender`, `JsonRenderClient`, and `useJsonRender`. Covers custom component registries, client-side interactivity, state management, and streaming integration. ## react-pdf Teaches agents how to generate PDFs from JSON specs using `@react-pdf/renderer`. Covers the PDF-specific component registry, page layout, and styling. ## react-email Teaches agents how to render JSON specs as HTML or plain-text emails using React Email components. Covers the email-specific registry and rendering pipeline. ## react-native Teaches agents how to render JSON specs as native mobile UIs with React Native. Covers the native component registry and platform-specific considerations. ## shadcn Teaches agents how to use the pre-built shadcn/ui component registry with json-render. Includes Radix UI primitives, Tailwind styling, and the full set of available shadcn components. ## image Teaches agents how to turn JSON specs into SVG and PNG images using Satori. Covers the image-specific registry, dimensions, fonts, and rendering options. ## remotion Teaches agents how to generate videos from JSON timeline specs using Remotion. Covers compositions, sequences, timeline structure, and video rendering. ## vue Teaches agents how to render JSON specs as Vue 3 component trees. Covers the Vue renderer API, custom component registries, and reactivity integration. ## svelte Teaches agents how to render JSON specs as Svelte 5 component trees. Covers the Svelte renderer API and component registration. ## solid Teaches agents how to render JSON specs as SolidJS component trees. Covers Solid-specific reactive patterns, provider wiring, bindings, actions, and streaming. ## codegen Teaches agents how to use code generation utilities to export UI specs as framework-specific source code. Covers the codegen pipeline and custom exporter creation. ## mcp Teaches agents how to build MCP Apps that serve json-render UIs inside AI tools like Claude, ChatGPT, Cursor, and VS Code. Covers MCP server setup, tool definitions, and UI streaming. ## redux Teaches agents how to connect a Redux store to json-render's `StateStore` interface for state-driven UIs. ## zustand Teaches agents how to connect a Zustand store to json-render's `StateStore` interface for lightweight state management. ## jotai Teaches agents how to connect Jotai atoms to json-render's `StateStore` interface for atomic state management. ## xstate Teaches agents how to connect an XState Store to json-render's `StateStore` interface for state-machine-driven UIs. ## Source All skill files are in the [`skills/`](https://github.com/vercel-labs/json-render/tree/main/skills) directory of the repository. ================================================ FILE: apps/web/app/(main)/docs/specs/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/specs") # Specs A spec is a JSON document that describes your UI. ## What is a Spec? A spec (specification) is the actual JSON that describes a UI. It uses components from a [catalog](/docs/catalog) and can optionally follow a [schema](/docs/schemas). Specs can be: - Generated by AI in real-time - Stored in a database - Streamed progressively from a server - Hand-authored as JSON files json-render is schema-agnostic — your specs can follow any JSON structure you choose. ## Example Specs ### Simple Spec A basic spec using the `@json-render/react` schema. Note the flat structure with a `root` key and `elements` map: ```json { "root": "card-1", "elements": { "card-1": { "type": "Card", "props": { "title": "Welcome" }, "children": ["text-1"] }, "text-1": { "type": "Text", "props": { "content": { "$state": "/user/greeting" } }, "children": [] } } } ``` ### Complex Spec A more complex spec with multiple nested elements: ```json { "root": "card-1", "elements": { "card-1": { "type": "Card", "props": { "title": "User Profile", "padding": "md" }, "children": ["row-1", "button-1"] }, "row-1": { "type": "Row", "props": { "gap": "md" }, "children": ["avatar-1", "stack-1"] }, "avatar-1": { "type": "Avatar", "props": { "src": { "$state": "/user/avatar" }, "alt": { "$state": "/user/name" } }, "children": [] }, "stack-1": { "type": "Stack", "props": { "gap": "sm" }, "children": ["name-text", "email-text"] }, "name-text": { "type": "Text", "props": { "content": { "$state": "/user/name" }, "variant": "heading" }, "children": [] }, "email-text": { "type": "Text", "props": { "content": { "$state": "/user/email" }, "variant": "caption" }, "children": [] }, "button-1": { "type": "Button", "props": { "label": "Edit Profile" }, "children": [] } } } ``` ### Block-Level Spec A high-level spec using semantic blocks for page layouts: ```json { "root": "page", "elements": { "page": { "type": "Page", "props": {}, "children": ["header", "hero", "features", "footer"] }, "header": { "type": "Header", "props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] }, "children": [] }, "hero": { "type": "Hero", "props": { "title": "Build UIs with JSON", "subtitle": "Let AI generate your interfaces", "ctaLabel": "Get Started", "ctaHref": "/docs" }, "children": [] }, "features": { "type": "Features", "props": { "columns": 3 }, "children": ["feature-1", "feature-2", "feature-3"] }, "feature-1": { "type": "Feature", "props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" }, "children": [] }, "feature-2": { "type": "Feature", "props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" }, "children": [] }, "feature-3": { "type": "Feature", "props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" }, "children": [] }, "footer": { "type": "Footer", "props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] }, "children": [] } } } ``` ## Spec Anatomy Specs are schema-agnostic — the JSON structure is entirely up to you. The examples below use the `root` + `elements` flat tree format from the `@json-render/react` schema, which is optimized for AI generation and streaming. ### Root and Elements In the React schema, a spec has a `root` key pointing to the entry element, and an `elements` map containing all elements: ```json { "root": "card-1", "elements": { "card-1": { "type": "Card", "props": { "title": "My Card" }, "children": ["text-1"] }, "text-1": { ... } } } ``` ### Element Structure Each element in the map has a consistent shape: ```json { "type": "ComponentName", "props": { "label": "Hello" }, "children": ["child-1", "child-2"] } ``` - `type` — Component type from your catalog - `props` — Component properties - `children` — Array of child element keys ### Dynamic Data Props can reference data from the state model using `$state` expressions. The value is a JSON Pointer (RFC 6901) path into the state: ```json { "type": "Metric", "props": { "label": "Total Revenue", "value": { "$state": "/metrics/revenue" }, "change": { "$state": "/metrics/revenueChange" } }, "children": [] } ``` See [Data Binding](/docs/data-binding) for the full reference including `$item`, `$index`, repeat, and two-way binding. ### Conditional Visibility Control when elements appear using the `visible` property: ```json { "type": "Alert", "props": { "message": "You have unsaved changes" }, "children": [], "visible": { "$state": "/form/isDirty", "eq": true } } ``` ## Working with Specs ### Validating a Spec Use `validateSpec` from `@json-render/core` to check a spec for structural issues: ```typescript import { validateSpec } from '@json-render/core'; const result = validateSpec(spec); if (!result.valid) { console.error('Invalid spec:', result.issues); } ``` ### Rendering a Spec (React) With `@json-render/react`, wrap the `Renderer` in providers to supply state and visibility: ```tsx import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react'; import { registry } from './registry'; function MyApp({ spec, initialState }) { return ( ); } ``` See the [@json-render/react API reference](/docs/api/react) for full provider and hook documentation. ### Streaming a Spec (React) With `@json-render/react`, use the `useUIStream` hook to stream specs incrementally: ```tsx import { useUIStream } from '@json-render/react'; function GenerativeUI() { const { spec, isStreaming } = useUIStream({ api: '/api/generate', }); return ( ); } ``` See [Streaming](/docs/streaming) for the full SpecStream format and server-side setup. ## Spec Sources Specs can come from various sources: - **AI Generation** — LLMs generate specs based on prompts and catalog - **Database** — Store specs as JSON and load dynamically - **API Response** — Server returns specs based on user/context - **Static Files** — Pre-built specs for known UI patterns ## Next Learn about [catalogs](/docs/catalog) — the vocabulary of components available in your specs. ================================================ FILE: apps/web/app/(main)/docs/streaming/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/streaming") # Streaming Progressively render UI as AI generates it. ## SpecStream Format json-render uses **SpecStream**, a JSONL-based streaming format where each line is a JSON patch operation that progressively builds your spec: ```json {"op":"add","path":"/root","value":"root"} {"op":"add","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}} {"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}} {"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}} ``` ## Patch Operations (RFC 6902) SpecStream uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations: - `add` — Add a value at a path (creates or replaces for objects, inserts for arrays) - `remove` — Remove the value at a path - `replace` — Replace an existing value at a path - `move` — Move a value from one path to another (requires `from`) - `copy` — Copy a value from one path to another (requires `from`) - `test` — Assert that a value at a path equals the given value ## Path Format Paths follow JSON Pointer (RFC 6901) into the spec object: ```bash /root -> Root element key (string) /elements/card-1 -> Element with key "card-1" /elements/card-1/props -> Props of card-1 /elements/card-1/children -> Children of card-1 ``` ## Server-Side Setup Ensure your API route streams properly: ```typescript import { streamText } from 'ai'; import { catalog } from '@/lib/catalog'; export async function POST(req: Request) { const { prompt } = await req.json(); const result = streamText({ model: 'anthropic/claude-haiku-4.5', system: catalog.prompt(), prompt, }); // Return as a streaming response return result.toTextStreamResponse(); } ``` ## Low-Level SpecStream API For custom or framework-agnostic streaming implementations, use the SpecStream compiler from `@json-render/core` directly: ```typescript import { createSpecStreamCompiler } from '@json-render/core'; // Create a compiler for your spec type const compiler = createSpecStreamCompiler(); const decoder = new TextDecoder(); // Process streaming chunks from AI async function processStream(reader: ReadableStreamDefaultReader) { while (true) { const { done, value } = await reader.read(); if (done) break; // Decode the Uint8Array chunk to a string const chunk = decoder.decode(value, { stream: true }); const { result, newPatches } = compiler.push(chunk); if (newPatches.length > 0) { // Update UI with partial result setSpec(result); } } // Get final compiled result return compiler.getResult(); } ``` ### One-Shot Compilation For non-streaming scenarios, compile entire SpecStream at once: ```typescript import { compileSpecStream } from '@json-render/core'; const jsonl = `{"op":"add","path":"/root","value":"card-1"} {"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Hello"},"children":[]}}`; const spec = compileSpecStream(jsonl); // { root: "card-1", elements: { "card-1": { type: "Card", props: { title: "Hello" }, children: [] } } } ``` ## Usage with React `@json-render/react` provides the `useUIStream` hook, which wraps the low-level compiler in a React-friendly API with state management, error handling, and abort support. ### useUIStream Hook ```tsx import { useUIStream } from '@json-render/react'; function App() { const { spec, // Current UI spec state isStreaming, // True while streaming error, // Any error that occurred send, // Function to start generation clear, // Function to reset spec and error } = useUIStream({ api: '/api/generate', onComplete: (spec) => {}, // Optional: called when streaming completes onError: (error) => {}, // Optional: called when an error occurs }); } ``` ### Progressive Rendering The Renderer automatically updates as the spec changes: ```tsx function App() { const { spec, isStreaming } = useUIStream({ api: '/api/generate' }); return (
{isStreaming && }
); } ``` ### Aborting Streams Calling `send` again automatically aborts the previous request. Use `clear` to reset the spec and error state: ```tsx function App() { const { isStreaming, send, clear } = useUIStream({ api: '/api/generate', }); return (
); } ``` See the [@json-render/react API reference](/docs/api/react) for full `useUIStream` documentation. ================================================ FILE: apps/web/app/(main)/docs/validation/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/validation") # Validation Validate form inputs with built-in and custom functions. ## Built-in Validators json-render includes common validation functions: - `required` — Value must be non-empty - `email` — Valid email format - `minLength` — Minimum string length (args: `{ "min": N }`) - `maxLength` — Maximum string length (args: `{ "max": N }`) - `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`) - `min` — Minimum numeric value (args: `{ "min": N }`) - `max` — Maximum numeric value (args: `{ "max": N }`) - `numeric` — Value must be a number - `url` — Valid URL format - `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`) - `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`) - `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`) - `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`) - `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`) ## Using Validation in JSON Use `{ "$bindState": "/path" }` on the value prop for two-way binding. Validation checks run against the value at the bound path (available as `bindings?.value` in components): ```json { "type": "TextField", "props": { "label": "Email", "value": { "$bindState": "/form/email" }, "checks": [ { "type": "required", "message": "Email is required" }, { "type": "email", "message": "Invalid email format" } ], "validateOn": "blur" } } ``` ## Validation with Parameters ```json { "type": "TextField", "props": { "label": "Password", "value": { "$bindState": "/form/password" }, "checks": [ { "type": "required", "message": "Password is required" }, { "type": "minLength", "args": { "min": 8 }, "message": "Password must be at least 8 characters" }, { "type": "pattern", "args": { "pattern": "[A-Z]" }, "message": "Must contain at least one uppercase letter" } ] } } ``` ## Custom Validation Functions Define custom validators in your catalog's `functions` field. The catalog itself is framework-agnostic — only the `schema` import varies by platform: ```typescript import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema' import { z } from 'zod'; const catalog = defineCatalog(schema, { components: { /* ... */ }, functions: { isValidPhone: { description: 'Validates phone number format', }, isUniqueEmail: { description: 'Checks if email is not already registered', }, }, }); ``` ## Usage with React In `@json-render/react`, use `ValidationProvider` to supply implementations for your custom validators: ```tsx import { ValidationProvider } from '@json-render/react'; function App() { const customValidators = { isValidPhone: (value) => { const phoneRegex = /^\+?[1-9]\d{1,14}$/; return phoneRegex.test(value); }, isUniqueEmail: async (value) => { const response = await fetch(`/api/check-email?email=${value}`); const { available } = await response.json(); return available; }, }; return ( {/* Your UI */} ); } ``` ### Using in Components The `useFieldValidation` and `useBoundProp` hooks wire validation into your registry components. Validation uses the path from `bindings?.value` (the bound state path): ```tsx import { useFieldValidation, useBoundProp } from '@json-render/react'; function TextField({ props, bindings }) { const [value, setValue] = useBoundProp(props.value, bindings?.value); const { errors, isValid, validate, touch, clear } = useFieldValidation( bindings?.value ?? null, { checks: props.checks, validateOn: props.validateOn } ); return (
setValue(e.target.value)} onBlur={() => validate()} /> {errors.map((error, i) => (

{error}

))}
); } ``` See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation. ## Cross-Field Validation Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password": ```json { "type": "Input", "props": { "label": "Confirm Password", "value": { "$bindState": "/form/confirmPassword" }, "checks": [ { "type": "required", "message": "Please confirm your password" }, { "type": "matches", "args": { "other": { "$state": "/form/password" } }, "message": "Passwords must match" } ] } } ``` Other cross-field examples: ```json { "checks": [ { "type": "greaterThan", "args": { "other": { "$state": "/form/startDate" } }, "message": "End date must be after start date" } ] } ``` ```json { "checks": [ { "type": "requiredIf", "args": { "field": { "$state": "/form/enableNotifications" } }, "message": "Email is required when notifications are enabled" } ] } ``` ## Conditional Validation Use the `enabled` field in the validation config to only run checks when a condition is met: ```json { "type": "Input", "props": { "label": "Company Name", "value": { "$bindState": "/form/company" }, "checks": [ { "type": "required", "message": "Company name is required" } ] } } ``` In the component implementation, you can pass `enabled` to `useFieldValidation`: ```typescript useFieldValidation(bindings?.value ?? "", { checks: props.checks ?? [], enabled: { "$state": "/form/accountType", eq: "business" }, }); ``` This only validates the company name when the account type is "business". ## Validation Timing Control when validation runs with `validateOn`: - `change` — Validate on every input change - `blur` — Validate when field loses focus (default for Input, Textarea) - `submit` — Validate only on form submission ## Form-Level Validation Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding: ```json { "type": "Button", "props": { "label": "Submit" }, "on": { "press": [ { "action": "validateForm", "params": { "statePath": "/formResult" } }, { "action": "submitForm" } ] }, "children": [] } ``` The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed. > **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid. ## Next - [Computed Values](/docs/computed-values) — derive dynamic prop values - [Watchers](/docs/watchers) — react to state changes - [Generation Modes](/docs/generation-modes) — how AI generates specs ================================================ FILE: apps/web/app/(main)/docs/visibility/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/visibility") # Visibility Conditionally show or hide components based on state values and logic. ## State-Based Visibility Show/hide based on state values. Use `$state` with a JSON Pointer path: ```json { "type": "Alert", "props": { "message": "Form has errors" }, "visible": { "$state": "/form/hasErrors" } } ``` Visible when `/form/hasErrors` is truthy. ### Negation Use `not: true` to invert a condition: ```json { "type": "WelcomeBanner", "visible": { "$state": "/user/hasSeenWelcome", "not": true } } ``` Visible when `/user/hasSeenWelcome` is falsy. ## Auth-Based Visibility Show/hide based on authentication state. Expose your auth state in the state model (e.g. at `/auth/isSignedIn`): ```json { "type": "AdminPanel", "visible": { "$state": "/auth/isSignedIn" } } ``` For signed-out only: ```json { "type": "LoginPrompt", "visible": { "$state": "/auth/isSignedIn", "not": true } } ``` ## Comparison Operators Compare a state value to a literal or another state path. Use **one operator per condition** -- if multiple are provided, only the first one is evaluated (precedence: `eq` > `neq` > `gt` > `gte` > `lt` > `lte`). Add `"not": true` to invert the result of any condition. ```json // Equal { "visible": { "$state": "/user/role", "eq": "admin" } } // Not equal { "visible": { "$state": "/tab", "neq": "home" } } // Greater than { "visible": { "$state": "/cart/total", "gt": 100 } } // Greater than or equal { "visible": { "$state": "/cart/itemCount", "gte": 1 } } // Less than { "visible": { "$state": "/cart/total", "lt": 1000 } } // Less than or equal { "visible": { "$state": "/cart/itemCount", "lte": 10 } } ``` Comparison values can be literals or state references: ```json { "visible": { "$state": "/user/balance", "gte": { "$state": "/order/minimum" } } } ``` ## Combining Conditions (AND) Place multiple conditions in an array for implicit AND: ```json { "type": "SubmitButton", "visible": [ { "$state": "/form/isValid" }, { "$state": "/form/hasChanges" } ] } ``` All conditions must be true for the element to be visible. ## OR Conditions Use `$or` when at least one condition should be true: ```json { "type": "SpecialOffer", "visible": { "$or": [ { "$state": "/user/isVIP" }, { "$state": "/cart/total", "gt": 200 } ]} } ``` Visible when the user is VIP **or** the cart total exceeds 200. `$or` can contain any visibility conditions, including nested arrays (AND) and comparisons. ## Explicit AND Use `$and` when you need to nest AND logic inside `$or`: ```json { "type": "PromoCard", "visible": { "$or": [ { "$and": [ { "$state": "/user/isVIP" }, { "$state": "/cart/total", "gt": 50 } ]}, { "$state": "/promo/active" } ]} } ``` For top-level AND, the implicit array form is simpler: `[condition, condition]`. Use `$and` only when nesting inside `$or`. ## Always / Never Use boolean literals for constant visibility: ```json { "type": "Footer", "visible": true } ``` ```json { "type": "DeprecatedPanel", "visible": false } ``` ## Repeat-Scoped Conditions Inside a [repeat](/docs/data-binding#repeat), use `$item` and `$index` conditions to show/hide based on the current item: ### `$item` — Condition on item field ```json { "type": "Badge", "props": { "label": "Overdue" }, "visible": { "$item": "isOverdue" } } ``` With comparison: ```json { "type": "DiscountTag", "visible": { "$item": "price", "gt": 100 } } ``` ### `$index` — Condition on array index ```json { "type": "Divider", "visible": { "$index": true, "gt": 0 } } ``` This shows the divider for every item except the first (index 0). `$item` and `$index` conditions support the same comparison operators as `$state` (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`). ## Complex Example ```json { "type": "RefundButton", "props": { "label": "Process Refund" }, "visible": [ { "$state": "/auth/isSignedIn" }, { "$state": "/user/role", "eq": "support" }, { "$state": "/order/amount", "gt": 0 }, { "$state": "/order/isRefunded", "not": true } ] } ``` ## Quick Reference
Condition Syntax
Truthiness {'{ "$state": "/path" }'}
Falsy (not) {'{ "$state": "/path", "not": true }'}
Equal {'{ "$state": "/path", "eq": value }'}
Not equal {'{ "$state": "/path", "neq": value }'}
Greater than {'{ "$state": "/path", "gt": number }'}
Greater or equal {'{ "$state": "/path", "gte": number }'}
Less than {'{ "$state": "/path", "lt": number }'}
Less or equal {'{ "$state": "/path", "lte": number }'}
Item field (repeat) {'{ "$item": "field" }'}
Item comparison {'{ "$item": "field", "eq": value }'}
Index (repeat) {'{ "$index": true, "gt": 0 }'}
AND (implicit) {"[ condition, condition ]"}
AND (explicit) {'{ "$and": [ condition, condition ] }'}
OR {'{ "$or": [ condition, condition ] }'}
Always {"true"}
Never {"false"}
Comparison values can be literals or state references for state-to-state comparisons: ```json { "$state": "/a", "eq": { "$state": "/b" } } ``` ## Usage with React In `@json-render/react`, wrap your app with `VisibilityProvider` to enable conditional rendering. The `Renderer` handles visibility automatically — elements with unmet conditions are not rendered. ```tsx import { VisibilityProvider, StateProvider } from '@json-render/react'; function App() { return ( {/* Components can now use visibility conditions */} ); } ``` For advanced use cases, the `useIsVisible` hook lets you evaluate visibility conditions programmatically: ```tsx import { useIsVisible } from '@json-render/react'; function ConditionalContent({ condition, children }) { const isVisible = useIsVisible(condition); if (!isVisible) return null; return
{children}
; } ``` See the [@json-render/react API reference](/docs/api/react) for full details. ## Next Learn about [form validation](/docs/validation). ================================================ FILE: apps/web/app/(main)/docs/watchers/page.mdx ================================================ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("docs/watchers") # Watchers React to state changes by triggering actions when watched paths update. ## The `watch` Field Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically. `watch` is a **top-level field** on the element (sibling of `type`, `props`, `children`) — not inside `props`. ```json { "type": "Select", "props": { "label": "Country", "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] }, "watch": { "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } } }, "children": [] } ``` When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options. ## Cascading Selects A common pattern is cascading dropdowns where selecting a value in one field loads options for another: ```json { "root": "form", "elements": { "form": { "type": "Stack", "props": { "direction": "vertical", "gap": "md" }, "children": ["country-select", "city-select"] }, "country-select": { "type": "Select", "props": { "label": "Country", "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] }, "watch": { "/form/country": [ { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }, { "action": "setState", "params": { "statePath": "/form/city", "value": "" } } ] }, "children": [] }, "city-select": { "type": "Select", "props": { "label": "City", "value": { "$bindState": "/form/city" }, "options": { "$state": "/availableCities" }, "placeholder": "Select a city" }, "children": [] } }, "state": { "form": { "country": "", "city": "" }, "availableCities": [] } } ``` The watcher on `country-select` fires two actions when the country changes: 1. `loadCities` — fetches and writes city options to `/availableCities` 2. `setState` — resets the city selection The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded. ### Action Handler ```typescript const handlers = { loadCities: async (params) => { const cities = await fetchCities(params.country); // setState is called by the runtime to write the result return cities; }, }; ``` Or with `defineRegistry`: ```typescript const { registry, handlers } = defineRegistry(catalog, { components: { /* ... */ }, actions: { loadCities: async (params, setState) => { const response = await fetch(`/api/cities?country=${params.country}`); const cities = await response.json(); setState('/availableCities', cities); }, }, }); ``` ## Multiple Watchers An element can watch multiple state paths. Each path maps to one or more action bindings: ```json { "watch": { "/form/startDate": { "action": "validateDateRange" }, "/form/endDate": { "action": "validateDateRange" }, "/form/quantity": [ { "action": "recalculateTotal" }, { "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } } ] } } ``` ## Behavior - Watchers only fire on **value changes**, not on the initial render - Comparison is by reference (`===`), not deep equality - Action params support the same expressions as event bindings (`$state`, `$item`, `$index`) - Multiple action bindings on the same path execute sequentially ## When to Use `watch` vs `on`
Mechanism Trigger Use Case
on User interaction (press, change, blur) Button clicks, input changes, form submissions
watch State value change (any source) Cascading data, derived state, cross-field sync
Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects. ## Next - [Data Binding](/docs/data-binding) — connect elements to state - [Computed Values](/docs/computed-values) — derive prop values - [Visibility](/docs/visibility) — conditionally show or hide elements ================================================ FILE: apps/web/app/(main)/examples/page.tsx ================================================ "use client"; import { useState } from "react"; import { Badge } from "@/components/ui/badge"; import { examples, allTags, getGitHubUrl, type Example } from "@/lib/examples"; import { cn } from "@/lib/utils"; function ExampleCard({ example }: { example: Example }) { return (

{example.title}

{example.description}

{example.tags.map((tag) => ( {tag} ))}
{example.demoUrl && ( Live Demo )} Source
); } export default function ExamplesPage() { const [activeTag, setActiveTag] = useState(null); const filtered = activeTag ? examples.filter((e) => e.tags.includes(activeTag)) : examples; return (

Examples

Explore json-render across frameworks, renderers, and use cases.

{allTags.map((tag) => ( ))}
{filtered.map((example) => ( ))}
{filtered.length === 0 && (

No examples match the selected filter.

)}
); } ================================================ FILE: apps/web/app/(main)/layout.tsx ================================================ import { Header } from "@/components/header"; export default function MainLayout({ children, }: { children: React.ReactNode; }) { return (
{children}
); } ================================================ FILE: apps/web/app/(main)/page.tsx ================================================ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Demo } from "@/components/demo"; import { Code } from "@/components/code"; import { CopyButton } from "@/components/copy-button"; export default function Home() { return ( <> {/* Hero */}

The Generative UI Framework

AI → json-render → UI

Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.

npm install @json-render/core @json-render/react
{/* How it works */}
01

Define Your Catalog

Set the guardrails. Define which components, actions, and data bindings AI can use.

02

AI Generates

Describe what you want. AI generates JSON constrained to your catalog. Every interface is unique.

03

Render Instantly

Stream the response. Your components render progressively as JSON arrives.

{/* Code example */}

Define your catalog

Components, actions, and validation functions.

{`import { defineSchema, defineCatalog } from '@json-render/core'; import { z } from 'zod'; const schema = defineSchema({ /* ... */ }); export const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string(), description: z.string().nullable(), }), hasChildren: true, }, Metric: { props: z.object({ label: z.string(), statePath: z.string(), format: z.enum(['currency', 'percent']), }), }, }, actions: { export: { params: z.object({ format: z.string() }) }, }, });`}

AI generates JSON

Constrained output that your components render natively.

{`{ "root": "dashboard", "elements": { "dashboard": { "type": "Card", "props": { "title": "Revenue Dashboard" }, "children": ["revenue"] }, "revenue": { "type": "Metric", "props": { "label": "Total Revenue", "statePath": "/metrics/revenue", "format": "currency" } } } }`}
{/* Code Export */}

Export as Code

Export generated UI as standalone React components. No runtime dependencies required.

Generated UI Tree

AI generates a JSON structure from the user's prompt.

{`{ "root": "card", "elements": { "card": { "type": "Card", "props": { "title": "Revenue" }, "children": ["metric", "chart"] }, "metric": { "type": "Metric", "props": { "label": "Total Revenue", "statePath": "analytics/revenue", "format": "currency" } }, "chart": { "type": "Chart", "props": { "statePath": "analytics/salesByRegion" } } } }`}

Exported React Code

Export as a standalone Next.js project with all components.

{`"use client"; import { Card, Metric, Chart } from "@/components/ui"; const data = { analytics: { revenue: 125000, salesByRegion: [ { label: "US", value: 45000 }, { label: "EU", value: 35000 }, ], }, }; export default function Page() { return ( ); }`}

The export includes{" "} package.json, component files, styles, and everything needed to run independently.

{/* Features */}

Features

{[ { title: "Generative UI", desc: "Generate dynamic, personalized interfaces from prompts with AI", }, { title: "Guardrails", desc: "AI can only use components you define in the catalog", }, { title: "Streaming", desc: "Progressive rendering as JSON streams from the model", }, { title: "React & React Native", desc: "Render on web and mobile from the same catalog and spec format", }, { title: "Data Binding", desc: "Connect props to state with $state, $item, $index, and two-way binding", }, { title: "Code Export", desc: "Export as standalone React code with no runtime dependencies", }, ].map((feature) => (

{feature.title}

{feature.desc}

))}
{/* CTA */}

Get started

npm install @json-render/core @json-render/react
); } ================================================ FILE: apps/web/app/api/docs-chat/route.ts ================================================ import { readFile } from "fs/promises"; import { join } from "path"; import { convertToModelMessages, stepCountIs, streamText } from "ai"; import type { ModelMessage, UIMessage } from "ai"; import { createBashTool } from "bash-tool"; import { headers } from "next/headers"; import { allDocsPages } from "@/lib/docs-navigation"; import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown"; import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit"; export const maxDuration = 60; const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render, a library for AI-generated UI with guardrails. GitHub repository: https://github.com/vercel-labs/json-render Documentation: https://json-render.dev/docs npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill ". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details. You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory. When answering questions: - Use the bash tool to list files (ls /workspace/docs/) or search for content (grep -r "keyword" /workspace/docs/) - Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/docs/index.md") - Do NOT use bash to write, create, modify, or delete files (no tee, cat >, sed -i, echo >, cp, mv, rm, mkdir, touch, etc.) — you are read-only - Always base your answers on the actual documentation content - Be concise and accurate - If the docs don't cover a topic, say so honestly - Do NOT include source references or file paths in your response - Do NOT use emojis in your responses`; async function loadDocsFiles(): Promise> { const files: Record = {}; const results = await Promise.allSettled( allDocsPages.map(async (page) => { const slug = page.href === "/docs" ? "" : page.href.replace(/^\/docs\/?/, ""); const filePath = slug ? join( process.cwd(), "app", "(main)", "docs", ...slug.split("/"), "page.mdx", ) : join(process.cwd(), "app", "(main)", "docs", "page.mdx"); const raw = await readFile(filePath, "utf-8"); const md = mdxToCleanMarkdown(raw); const fileName = slug ? `/docs/${slug}.md` : "/docs/index.md"; return { fileName, md }; }), ); for (const result of results) { if (result.status === "fulfilled") { files[result.value.fileName] = result.value.md; } } return files; } function addCacheControl(messages: ModelMessage[]): ModelMessage[] { if (messages.length === 0) return messages; return messages.map((message, index) => { if (index === messages.length - 1) { return { ...message, providerOptions: { ...message.providerOptions, anthropic: { cacheControl: { type: "ephemeral" } }, }, }; } return message; }); } export async function POST(req: Request) { const headersList = await headers(); const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous"; const [minuteResult, dailyResult] = await Promise.all([ minuteRateLimit.limit(ip), dailyRateLimit.limit(ip), ]); if (!minuteResult.success || !dailyResult.success) { const isMinuteLimit = !minuteResult.success; return new Response( JSON.stringify({ error: "Rate limit exceeded", message: isMinuteLimit ? "Too many requests. Please wait a moment before trying again." : "Daily limit reached. Please try again tomorrow.", }), { status: 429, headers: { "Content-Type": "application/json" }, }, ); } const { messages }: { messages: UIMessage[] } = await req.json(); const docsFiles = await loadDocsFiles(); const { tools: { bash, readFile }, } = await createBashTool({ files: docsFiles }); const result = streamText({ model: DEFAULT_MODEL, system: SYSTEM_PROMPT, messages: await convertToModelMessages(messages), stopWhen: stepCountIs(5), tools: { bash, readFile, }, prepareStep: ({ messages: stepMessages }) => ({ messages: addCacheControl(stepMessages), }), }); return result.toUIMessageStreamResponse(); } ================================================ FILE: apps/web/app/api/docs-markdown/route.ts ================================================ import { readFile } from "fs/promises"; import { join } from "path"; import { NextRequest, NextResponse } from "next/server"; import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown"; export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const docPath = searchParams.get("path"); if (!docPath) { return NextResponse.json( { error: "Missing ?path= parameter" }, { status: 400 }, ); } // Sanitize path: only allow docs paths, no traversal const normalized = docPath .replace(/^\//, "") .replace(/\.\./g, "") .replace(/[^a-zA-Z0-9/-]/g, ""); if (!normalized.startsWith("docs")) { return NextResponse.json({ error: "Invalid path" }, { status: 400 }); } // Map URL path to file path // /docs -> /app/(main)/docs/page.mdx // /docs/installation -> /app/(main)/docs/installation/page.mdx const slug = normalized === "docs" ? "" : normalized.replace(/^docs\/?/, ""); const filePath = slug ? join( process.cwd(), "app", "(main)", "docs", ...slug.split("/"), "page.mdx", ) : join(process.cwd(), "app", "(main)", "docs", "page.mdx"); try { const raw = await readFile(filePath, "utf-8"); const markdown = mdxToCleanMarkdown(raw); return new NextResponse(markdown, { headers: { "Content-Type": "text/markdown; charset=utf-8", "Cache-Control": "public, max-age=3600", }, }); } catch { return NextResponse.json({ error: "Page not found" }, { status: 404 }); } } ================================================ FILE: apps/web/app/api/generate/route.ts ================================================ import { streamText } from "ai"; import { headers } from "next/headers"; import type { Spec, EditMode } from "@json-render/core"; import { buildUserPrompt, buildEditUserPrompt, isNonEmptySpec, } from "@json-render/core"; import { yamlPrompt } from "@json-render/yaml"; import { stringify as yamlStringify } from "yaml"; import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit"; import { playgroundCatalog } from "@/lib/render/catalog"; export const maxDuration = 30; const PLAYGROUND_RULES = [ "NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.", "NEVER use page background colors (bg-gray-50) - the container has its own background.", "For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.", "For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.", "Wrap each repeated item in a Card for visual separation and structure.", "Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.", 'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).', ]; const MAX_PROMPT_LENGTH = 500; const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; function getSystemPrompt(isYaml: boolean, editModes?: EditMode[]): string { if (isYaml) { return yamlPrompt(playgroundCatalog, { mode: "standalone", customRules: PLAYGROUND_RULES, editModes: editModes ?? ["merge"], }); } return playgroundCatalog.prompt({ customRules: PLAYGROUND_RULES, editModes, }); } function buildYamlUserPrompt( prompt: string, previousSpec?: Spec | null, editModes?: EditMode[], ): string { if (isNonEmptySpec(previousSpec)) { return buildEditUserPrompt({ prompt, currentSpec: previousSpec, config: { modes: editModes ?? ["merge"] }, format: "yaml", maxPromptLength: MAX_PROMPT_LENGTH, serializer: (s) => yamlStringify(s, { indent: 2 }).trimEnd(), }); } const userText = prompt.slice(0, MAX_PROMPT_LENGTH); return [ userText, "", "Output the full spec in a ```yaml-spec fence. Stream progressively — output elements one at a time.", ].join("\n"); } export async function POST(req: Request) { const headersList = await headers(); const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous"; const [minuteResult, dailyResult] = await Promise.all([ minuteRateLimit.limit(ip), dailyRateLimit.limit(ip), ]); if (!minuteResult.success || !dailyResult.success) { const isMinuteLimit = !minuteResult.success; return new Response( JSON.stringify({ error: "Rate limit exceeded", message: isMinuteLimit ? "Too many requests. Please wait a moment before trying again." : "Daily limit reached. Please try again tomorrow.", }), { status: 429, headers: { "Content-Type": "application/json" }, }, ); } const { prompt, context, format, editModes } = await req.json(); const isYaml = format === "yaml"; const systemPrompt = getSystemPrompt(isYaml, editModes); const userPrompt = isYaml ? buildYamlUserPrompt(prompt, context?.previousSpec, editModes) : buildUserPrompt({ prompt, currentSpec: context?.previousSpec, maxPromptLength: MAX_PROMPT_LENGTH, editModes, }); const result = streamText({ model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL, system: [ { role: "system", content: systemPrompt, providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } }, }, }, ], prompt: userPrompt, temperature: 0.7, }); const encoder = new TextEncoder(); const textStream = result.textStream; const stream = new ReadableStream({ async start(controller) { for await (const chunk of textStream) { controller.enqueue(encoder.encode(chunk)); } try { const usage = await result.usage; const meta = JSON.stringify({ __meta: "usage", promptTokens: usage.inputTokens, completionTokens: usage.outputTokens, totalTokens: usage.totalTokens, cachedTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0, cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0, }); controller.enqueue(encoder.encode(`\n${meta}\n`)); } catch { // Usage not available } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } ================================================ FILE: apps/web/app/api/search/route.ts ================================================ import { NextRequest, NextResponse } from "next/server"; import { getSearchIndex } from "@/lib/search-index"; export async function GET(req: NextRequest) { const q = req.nextUrl.searchParams.get("q")?.trim().toLowerCase(); if (!q) { return NextResponse.json({ results: [] }); } const index = await getSearchIndex(); const terms = q.split(/\s+/).filter(Boolean); const results = index .map((entry) => { const titleLower = entry.title.toLowerCase(); const contentLower = entry.content.toLowerCase(); const titleMatch = terms.every((t) => titleLower.includes(t)); const contentMatch = terms.every((t) => contentLower.includes(t)); if (!titleMatch && !contentMatch) return null; let snippet = ""; if (contentMatch) { const firstTermIdx = Math.min( ...terms.map((t) => { const idx = contentLower.indexOf(t); return idx === -1 ? Infinity : idx; }), ); if (firstTermIdx !== Infinity) { const start = Math.max(0, firstTermIdx - 40); const end = Math.min(entry.content.length, firstTermIdx + 120); snippet = (start > 0 ? "..." : "") + entry.content.slice(start, end).replace(/\n/g, " ") + (end < entry.content.length ? "..." : ""); } } return { title: entry.title, href: entry.href, section: entry.section, snippet, score: titleMatch ? 2 : 1, }; }) .filter( ( r, ): r is { title: string; href: string; section: string; snippet: string; score: number; } => r !== null, ) .sort((a, b) => b.score - a.score) .slice(0, 20) .map(({ title, href, section, snippet }) => ({ title, href, section, snippet, })); return NextResponse.json( { results }, { headers: { "Cache-Control": "public, max-age=60" } }, ); } ================================================ FILE: apps/web/app/globals.css ================================================ @import "tailwindcss"; @import "tw-animate-css"; @source "../node_modules/streamdown/dist/index.js"; @custom-variant dark (&:is(.dark *)); :root { --radius: 0.5rem; --ds-gray-500: oklch(0.836 0 0); /* Monochrome light theme */ --background: oklch(1.0 0 0); --foreground: oklch(0.1 0 0); --card: oklch(0.98 0 0); --card-foreground: oklch(0.1 0 0); --popover: oklch(0.98 0 0); --popover-foreground: oklch(0.1 0 0); --primary: oklch(0.1 0 0); --primary-foreground: oklch(1.0 0 0); --secondary: oklch(0.92 0 0); --secondary-foreground: oklch(0.1 0 0); --muted: oklch(0.92 0 0); --muted-foreground: oklch(0.45 0 0); --accent: oklch(0.92 0 0); --accent-foreground: oklch(0.1 0 0); --destructive: oklch(0.55 0.2 25); --destructive-foreground: oklch(1.0 0 0); --border: oklch(0.85 0 0); --input: oklch(0.85 0 0); --ring: oklch(0.6 0 0); --chat-bg: oklch(0.95 0 0); } .dark { --ds-gray-500: oklch(0.39 0 0); /* Monochrome dark theme */ --background: oklch(0.0 0 0); --foreground: oklch(0.98 0 0); --card: oklch(0.08 0 0); --card-foreground: oklch(0.98 0 0); --popover: oklch(0.08 0 0); --popover-foreground: oklch(0.98 0 0); --primary: oklch(0.98 0 0); --primary-foreground: oklch(0.0 0 0); --secondary: oklch(0.15 0 0); --secondary-foreground: oklch(0.98 0 0); --muted: oklch(0.15 0 0); --muted-foreground: oklch(0.6 0 0); --accent: oklch(0.15 0 0); --accent-foreground: oklch(0.98 0 0); --destructive: oklch(0.65 0.2 25); --destructive-foreground: oklch(0.98 0 0); --border: oklch(0.25 0 0); --input: oklch(0.25 0 0); --ring: oklch(0.4 0 0); --chat-bg: oklch(0.25 0 0); } @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --radius-2xl: calc(var(--radius) + 8px); --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground antialiased; font-family: var(--font-geist-sans), system-ui, sans-serif; } ::selection { @apply bg-foreground text-background; } code { font-family: var(--font-geist-mono), ui-monospace, monospace; @apply bg-secondary px-1.5 py-0.5 rounded text-sm; } pre { font-family: var(--font-geist-mono), ui-monospace, monospace; @apply overflow-x-auto text-sm leading-relaxed; } pre code { @apply bg-transparent p-0; } /* Hide page scrollbar */ html { scrollbar-width: none; } html::-webkit-scrollbar { display: none; } } button { cursor: pointer; } /* Tool call shimmer animation */ @keyframes tool-shimmer { 0% { opacity: 0.5; } 50% { opacity: 1; } 100% { opacity: 0.5; } } .animate-tool-shimmer { animation: tool-shimmer 1.5s ease-in-out infinite; } /* Fix list rendering in chat content */ .docs-chat-content ul, .docs-chat-content ol { list-style-position: outside; padding-left: 1.25em; } .docs-chat-content li > p { display: inline; margin: 0; } .docs-chat-content li { margin-top: 0.5em; margin-bottom: 0.5em; } /* MDX table styles — applies to both GFM pipe tables and raw HTML tables */ .mdx-table th, .mdx-table td, article table th, article table td { border: 1px solid var(--border); padding: 0.75rem 1rem; text-align: left; } .mdx-table th, article table th { font-weight: 600; background-color: var(--muted); } .mdx-table td, article table td { color: var(--muted-foreground); } article table { width: 100%; font-size: 0.875rem; border-collapse: collapse; margin: 1.5rem 0; } /* Shiki dual theme support */ .shiki, .shiki span { color: var(--shiki-light) !important; background-color: var(--shiki-light-bg) !important; } .dark .shiki, .dark .shiki span { color: var(--shiki-dark) !important; background-color: var(--shiki-dark-bg) !important; } ================================================ FILE: apps/web/app/layout.tsx ================================================ import type { Metadata } from "next"; import localFont from "next/font/local"; import { GeistPixelSquare } from "geist/font/pixel"; import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; import { DocsChat } from "@/components/docs-chat"; import { Analytics } from "@vercel/analytics/next"; import { SpeedInsights } from "@vercel/speed-insights/next"; import { PAGE_TITLES } from "@/lib/page-titles"; import { cookies } from "next/headers"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", variable: "--font-geist-sans", }); const geistMono = localFont({ src: "./fonts/GeistMonoVF.woff", variable: "--font-geist-mono", }); export const metadata: Metadata = { metadataBase: new URL("https://json-render.dev"), title: { default: `json-render | ${PAGE_TITLES[""]}`, template: "%s | json-render", }, description: "The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.", keywords: [ "json-render", "generative UI", "AI UI generation", "user-generated interfaces", "React components", "React Native", "guardrails", "structured output", "dashboard builder", ], authors: [{ name: "Vercel Labs" }], creator: "Vercel Labs", openGraph: { type: "website", locale: "en_US", url: "https://json-render.dev", siteName: "json-render", title: "json-render | The Generative UI Framework", description: "The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.", images: [ { url: "/og", width: 1200, height: 630, alt: "json-render - The Generative UI Framework", }, ], }, twitter: { card: "summary_large_image", title: "json-render | The Generative UI Framework", description: "The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.", images: ["/og"], }, robots: { index: true, follow: true, }, icons: { icon: "/favicon.ico", }, }; export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { const cookieStore = await cookies(); const chatOpen = cookieStore.get("docs-chat-open")?.value === "true"; const chatWidth = Number(cookieStore.get("docs-chat-width")?.value) || 400; return ( {chatOpen && (
); }; ================================================ FILE: examples/dashboard/components/ui/avatar.tsx ================================================ "use client"; import * as React from "react"; import { Avatar as AvatarPrimitive } from "radix-ui"; import { cn } from "@/lib/utils"; function Avatar({ className, size = "default", ...props }: React.ComponentProps & { size?: "default" | "sm" | "lg"; }) { return ( ); } function AvatarImage({ className, ...props }: React.ComponentProps) { return ( ); } function AvatarFallback({ className, ...props }: React.ComponentProps) { return ( ); } function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { return ( svg]:hidden", "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", className, )} {...props} /> ); } function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { return (
); } function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) { return (
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", className, )} {...props} /> ); } export { Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount, }; ================================================ FILE: examples/dashboard/components/ui/badge.tsx ================================================ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { Slot } from "radix-ui"; import { cn } from "@/lib/utils"; const badgeVariants = cva( "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { variants: { variant: { default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", link: "text-primary underline-offset-4 [a&]:hover:underline", }, }, defaultVariants: { variant: "default", }, }, ); function Badge({ className, variant = "default", asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { const Comp = asChild ? Slot.Root : "span"; return ( ); } export { Badge, badgeVariants }; ================================================ FILE: examples/dashboard/components/ui/button.tsx ================================================ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { Slot } from "radix-ui"; import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8", "icon-lg": "size-10", }, }, defaultVariants: { variant: "default", size: "default", }, }, ); function Button({ className, variant = "default", size = "default", asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean; }) { const Comp = asChild ? Slot.Root : "button"; return ( ); } export { Button, buttonVariants }; ================================================ FILE: examples/dashboard/components/ui/card.tsx ================================================ import * as React from "react"; import { cn } from "@/lib/utils"; function Card({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardAction({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardContent({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return (
); } export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, }; ================================================ FILE: examples/dashboard/components/ui/chart.tsx ================================================ "use client"; import * as React from "react"; import * as RechartsPrimitive from "recharts"; import { cn } from "@/lib/utils"; // Format: { THEME_NAME: CSS_SELECTOR } const THEMES = { light: "", dark: ".dark" } as const; export type ChartConfig = { [k in string]: { label?: React.ReactNode; icon?: React.ComponentType; } & ( | { color?: string; theme?: never } | { color?: never; theme: Record } ); }; type ChartContextProps = { config: ChartConfig; }; const ChartContext = React.createContext(null); function useChart() { const context = React.useContext(ChartContext); if (!context) { throw new Error("useChart must be used within a "); } return context; } function ChartContainer({ id, className, children, config, ...props }: React.ComponentProps<"div"> & { config: ChartConfig; children: React.ComponentProps< typeof RechartsPrimitive.ResponsiveContainer >["children"]; }) { const uniqueId = React.useId(); const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; return (
{children}
); } const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const colorConfig = Object.entries(config).filter( ([, config]) => config.theme || config.color, ); if (!colorConfig.length) { return null; } return (
================================================ FILE: examples/vite-renderers/package.json ================================================ { "name": "vite-renderers", "version": "0.1.7", "private": true, "type": "module", "scripts": { "predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)", "dev": "portless vite-renderers.json-render vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "@json-render/core": "workspace:*", "@json-render/react": "workspace:*", "@json-render/solid": "workspace:*", "@json-render/svelte": "workspace:*", "@json-render/vue": "workspace:*", "react": "^19.2.4", "react-dom": "^19.2.4", "solid-js": "^1.9.11", "svelte": "^5.49.2", "vue": "^3.5.29", "zod": "4.3.6" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-vue": "^6.0.4", "typescript": "^5.9.3", "vite": "^7.3.1", "vite-plugin-solid": "^2.11.10" } } ================================================ FILE: examples/vite-renderers/src/main.ts ================================================ import "./shared/styles.css"; import { demoSpec } from "./spec"; type Renderer = "vue" | "react" | "svelte" | "solid"; const container = document.getElementById("renderer-root") as HTMLElement; let unmountCurrent: (() => void) | null = null; async function switchTo(renderer: Renderer) { unmountCurrent?.(); container.innerHTML = ""; if (renderer === "vue") { const mod = await import("./vue/mount.ts"); mod.mount(container, renderer, demoSpec); unmountCurrent = mod.unmount; } else if (renderer === "react") { const mod = await import("./react/mount.tsx"); mod.mount(container, renderer, demoSpec); unmountCurrent = mod.unmount; } else if (renderer === "svelte") { const mod = await import("./svelte/mount.ts"); mod.mount(container, renderer, demoSpec); unmountCurrent = mod.unmount; } else { const mod = await import("./solid/mount.tsx"); mod.mount(container, renderer, demoSpec); unmountCurrent = mod.unmount; } } // The RendererTabs component (rendered by JSON renderer) dispatches this event document.addEventListener("switch-renderer", (e: Event) => { switchTo((e as CustomEvent).detail as Renderer); }); // Default: Vue switchTo("vue"); ================================================ FILE: examples/vite-renderers/src/react/App.tsx ================================================ import { useMemo } from "react"; import type { Spec } from "@json-render/core"; import { StateProvider, ActionProvider, VisibilityProvider, ValidationProvider, Renderer, defineRegistry, useStateStore, } from "@json-render/react"; import { catalog } from "./catalog"; import { components } from "./registry"; import { actionStubs, makeHandlers } from "../shared/handlers"; const { registry } = defineRegistry(catalog, { components, actions: actionStubs, }); function DemoRenderer({ spec }: { spec: Spec }) { const { get, set } = useStateStore(); const handlers = useMemo(() => makeHandlers(get, set), [get, set]); return ( ); } export default function App({ initialRenderer = "vue", spec, }: { initialRenderer?: string; spec: Spec; }) { return (
); } ================================================ FILE: examples/vite-renderers/src/react/catalog.ts ================================================ import { schema } from "@json-render/react/schema"; import { catalogDef } from "../shared/catalog-def"; export const catalog = schema.createCatalog(catalogDef); export type AppCatalog = typeof catalog; ================================================ FILE: examples/vite-renderers/src/react/mount.tsx ================================================ import { createRoot, type Root } from "react-dom/client"; import type { Spec } from "@json-render/core"; import App from "./App"; let root: Root | null = null; export function mount(container: HTMLElement, renderer: string, spec: Spec) { root = createRoot(container); root.render(); } export function unmount() { root?.unmount(); root = null; } ================================================ FILE: examples/vite-renderers/src/react/registry.tsx ================================================ import type { Components } from "@json-render/react"; import type { AppCatalog } from "./catalog"; export const components: Components = { Stack: ({ props, children }) => (
{children}
), Card: ({ props, children }) => (
{props.title && (

{props.title}

)} {props.subtitle && (

{props.subtitle}

)} {children}
), Text: ({ props }) => ( {String(props.content ?? "")} ), Button: ({ props, emit }) => ( ), Badge: ({ props }) => ( {props.label} ), ListItem: ({ props, emit }) => (
emit("press")} className={[ "json-render-list-item", props.completed && "json-render-list-item--done", ] .filter(Boolean) .join(" ")} >
{props.completed ? "✓" : ""}
{props.title}
), RendererBadge: ({ props }) => ( {props.renderer === "vue" ? "Rendered with Vue" : props.renderer === "react" ? "Rendered with React" : props.renderer === "svelte" ? "Rendered with Svelte" : "Rendered with Solid"} ), RendererTabs: ({ props, emit }) => (
Render
), }; ================================================ FILE: examples/vite-renderers/src/shared/catalog-def.ts ================================================ import { z } from "zod"; /** * Shared catalog definition — imported by both vue/catalog.ts and react/catalog.ts. * Each renderer calls schema.createCatalog(catalogDef) with its own schema instance. */ export const catalogDef = { components: { Stack: { props: z.object({ gap: z.number().optional(), padding: z.number().optional(), direction: z.enum(["vertical", "horizontal"]).optional(), align: z.enum(["start", "center", "end"]).optional(), }), slots: ["default"], description: "Layout container that stacks children vertically or horizontally", }, Card: { props: z.object({ title: z.string().optional(), subtitle: z.string().optional(), }), slots: ["default"], description: "A card container with optional title and subtitle", }, Text: { props: z.object({ content: z.string(), size: z.enum(["sm", "md", "lg", "xl"]).optional(), weight: z.enum(["normal", "medium", "bold"]).optional(), color: z.string().optional(), }), slots: [], description: "Displays a text string", }, Button: { props: z.object({ label: z.string(), variant: z.enum(["primary", "secondary", "danger"]).optional(), disabled: z.boolean().optional(), }), slots: [], description: "A clickable button that emits a 'press' event", }, Badge: { props: z.object({ label: z.string(), color: z.string().optional(), }), slots: [], description: "A small badge/tag label", }, ListItem: { props: z.object({ title: z.string(), description: z.string().optional(), completed: z.boolean().optional(), }), slots: [], description: "A single item in a list", }, RendererTabs: { props: z.object({ renderer: z.string() }), slots: [], description: "Segmented tab control for switching between Vue, React, Svelte, and Solid renderers", }, RendererBadge: { props: z.object({ renderer: z.string() }), slots: [], description: "Badge indicating which renderer is currently active", }, }, actions: { increment: { params: z.object({}), description: "Increment the counter by 1", }, decrement: { params: z.object({}), description: "Decrement the counter by 1", }, reset: { params: z.object({}), description: "Reset the counter to 0" }, toggleItem: { params: z.object({ index: z.number() }), description: "Toggle the completed state of a todo item", }, switchToVue: { params: z.object({}), description: "Switch to the Vue renderer", }, switchToReact: { params: z.object({}), description: "Switch to the React renderer", }, switchToSvelte: { params: z.object({}), description: "Switch to the Svelte renderer", }, switchToSolid: { params: z.object({}), description: "Switch to the Solid renderer", }, }, }; ================================================ FILE: examples/vite-renderers/src/shared/handlers.ts ================================================ type Get = (path: string) => unknown; type Set = (path: string, value: unknown) => void; /** Stub actions for defineRegistry (no-ops; real logic is in makeHandlers) */ export const actionStubs = { increment: async () => {}, decrement: async () => {}, reset: async () => {}, toggleItem: async () => {}, switchToVue: async () => {}, switchToReact: async () => {}, switchToSvelte: async () => {}, switchToSolid: async () => {}, }; /** Creates action handlers that close over the state store's get/set */ export function makeHandlers(get: Get, set: Set) { return { increment: async () => { set("/count", Number(get("/count") || 0) + 1); }, decrement: async () => { set("/count", Math.max(0, Number(get("/count") || 0) - 1)); }, reset: async () => { set("/count", 0); }, toggleItem: async (params: Record) => { const index = params.index as number; const todos = ( get("/todos") as Array<{ id: number; title: string; completed: boolean; }> ).slice(); const item = todos[index]; if (item) todos[index] = { ...item, completed: !item.completed }; set("/todos", todos); }, switchToVue: async () => { document.dispatchEvent( new CustomEvent("switch-renderer", { detail: "vue" }), ); }, switchToReact: async () => { document.dispatchEvent( new CustomEvent("switch-renderer", { detail: "react" }), ); }, switchToSvelte: async () => { document.dispatchEvent( new CustomEvent("switch-renderer", { detail: "svelte" }), ); }, switchToSolid: async () => { document.dispatchEvent( new CustomEvent("switch-renderer", { detail: "solid" }), ); }, }; } ================================================ FILE: examples/vite-renderers/src/shared/styles.css ================================================ /* ---- Stack ---------------------------------------------------------------- */ .json-render-stack { display: flex; flex-direction: column; align-items: stretch; } .json-render-stack--horizontal { flex-direction: row; align-items: center; } .json-render-stack--align-start { align-items: flex-start; } .json-render-stack--align-center { align-items: center; } .json-render-stack--align-end { align-items: flex-end; } /* ---- Card ----------------------------------------------------------------- */ .json-render-card { background-color: white; border-radius: 12px; border: 1px solid #e5e7eb; padding: 20px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .json-render-card-title-wrap { margin-bottom: 4px; } .json-render-card-title { font-size: 16px; font-weight: 600; color: #111827; margin: 0; } .json-render-card-subtitle { font-size: 13px; color: #6b7280; margin: 0 0 12px 0; } /* ---- Text ----------------------------------------------------------------- */ .json-render-text { font-size: 14px; font-weight: 400; color: #111827; } .json-render-text--sm { font-size: 12px; } .json-render-text--lg { font-size: 16px; } .json-render-text--xl { font-size: 24px; } .json-render-text--medium { font-weight: 500; } .json-render-text--bold { font-weight: 700; } /* ---- Button --------------------------------------------------------------- */ .json-render-button { padding: 8px 16px; border-radius: 8px; border: none; cursor: pointer; font-weight: 500; font-size: 14px; transition: background 0.15s; background-color: #3b82f6; color: white; } .json-render-button:disabled { cursor: not-allowed; opacity: 0.5; } .json-render-button--primary { background-color: #3b82f6; color: white; } .json-render-button--secondary { background-color: #f3f4f6; color: #374151; } .json-render-button--danger { background-color: #fee2e2; color: #dc2626; } /* ---- Badge ---------------------------------------------------------------- */ .json-render-badge { display: inline-block; padding: 4px 12px; border-radius: 999px; font-size: 13px; font-weight: 500; background-color: #e0f2fe; color: #0369a1; border: 1px solid #bae6fd; } /* ---- ListItem ------------------------------------------------------------- */ .json-render-list-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; cursor: pointer; background-color: #f9fafb; border: 1px solid #e5e7eb; } .json-render-list-item--done { background-color: #f0fdf4; border-color: #bbf7d0; } .json-render-list-item-check { width: 18px; height: 18px; border-radius: 50%; border: 2px solid #d1d5db; background-color: transparent; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 11px; color: white; } .json-render-list-item-check--done { border-color: #16a34a; background-color: #16a34a; } .json-render-list-item-text { font-size: 14px; color: #111827; text-decoration: none; } .json-render-list-item-text--done { color: #6b7280; text-decoration: line-through; } /* ---- RendererBadge -------------------------------------------------------- */ .json-render-renderer-badge { display: inline-flex; align-items: center; gap: 5px; padding: 10px 15px; border-radius: 999px; font-size: 12px; font-weight: 500; /* default colors; overridden by renderer parent class below */ background-color: #e0f2fe; color: #0369a1; border: 1px solid #bae6fd; } .json-render-renderer-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; /* background overridden by renderer parent class below */ background-color: #0369a1; } /* ---- RendererTabs --------------------------------------------------------- */ .json-render-renderer-tabs-wrapper { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; } .json-render-renderer-tabs-label { font-size: 13px; color: #6b7280; font-weight: 500; } .json-render-renderer-tabs { display: inline-flex; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; } .json-render-renderer-tab { padding: 6px 16px; border: none; border-right: 0; cursor: pointer; font-size: 13px; font-weight: 500; background-color: white; color: #374151; transition: background 0.15s; } .json-render-renderer-tab:not(:last-child) { border-right: 1px solid #e5e7eb; } /* ---- Renderer-specific overrides ----------------------------------------- */ .renderer-vue .json-render-renderer-badge { color: #42b883; background-color: #42b88318; border-color: #42b88340; } .renderer-vue .json-render-renderer-dot { background-color: #42b883; } .renderer-vue .json-render-renderer-tab--active { background-color: #42b883; color: white; } .renderer-react .json-render-renderer-badge { color: #149eca; background-color: #149eca18; border-color: #149eca40; } .renderer-react .json-render-renderer-dot { background-color: #149eca; } .renderer-react .json-render-renderer-tab--active { background-color: #149eca; color: white; } .renderer-svelte .json-render-renderer-badge { color: #ff3e00; background-color: #ff3e0018; border-color: #ff3e0040; } .renderer-svelte .json-render-renderer-dot { background-color: #ff3e00; } .renderer-svelte .json-render-renderer-tab--active { background-color: #ff3e00; color: white; } .renderer-solid .json-render-renderer-badge { color: #2c4f7c; background-color: #2c4f7c18; border-color: #2c4f7c40; } .renderer-solid .json-render-renderer-dot { background-color: #2c4f7c; } .renderer-solid .json-render-renderer-tab--active { background-color: #2c4f7c; color: white; } ================================================ FILE: examples/vite-renderers/src/solid/App.tsx ================================================ import type { Spec } from "@json-render/core"; import { StateProvider } from "@json-render/solid"; import { DemoRenderer } from "./DemoRenderer"; export function App(props: { initialRenderer?: string; spec: Spec }) { const renderer = props.initialRenderer ?? "solid"; const initialState = { ...props.spec.state, renderer, }; return (
); } ================================================ FILE: examples/vite-renderers/src/solid/DemoRenderer.tsx ================================================ import type { Spec } from "@json-render/core"; import { ActionProvider, ValidationProvider, VisibilityProvider, Renderer, defineRegistry, useStateStore, } from "@json-render/solid"; import { catalog } from "./catalog"; import { components } from "./registry"; import { actionStubs, makeHandlers } from "../shared/handlers"; const { registry } = defineRegistry(catalog, { components, actions: actionStubs, }); export function DemoRenderer(props: { spec: Spec }) { const stateStore = useStateStore(); const handlers = makeHandlers(stateStore.get, stateStore.set); return ( ); } ================================================ FILE: examples/vite-renderers/src/solid/catalog.ts ================================================ import { schema } from "@json-render/solid/schema"; import { catalogDef } from "../shared/catalog-def"; export const catalog = schema.createCatalog(catalogDef); export type AppCatalog = typeof catalog; ================================================ FILE: examples/vite-renderers/src/solid/mount.tsx ================================================ import { render } from "solid-js/web"; import type { Spec } from "@json-render/core"; import { App } from "./App"; let dispose: (() => void) | null = null; export function mount(container: HTMLElement, renderer: string, spec: Spec) { dispose = render( () => , container, ); } export function unmount() { dispose?.(); dispose = null; } ================================================ FILE: examples/vite-renderers/src/solid/registry.tsx ================================================ import type { Components } from "@json-render/solid"; import type { AppCatalog } from "./catalog"; export const components: Components = { Stack: (renderProps) => (
{renderProps.children}
), Card: (renderProps) => (
{renderProps.props.title && (

{renderProps.props.title}

)} {renderProps.props.subtitle && (

{renderProps.props.subtitle}

)} {renderProps.children}
), Text: (renderProps) => ( {String(renderProps.props.content ?? "")} ), Button: (renderProps) => ( ), Badge: (renderProps) => ( {renderProps.props.label} ), ListItem: (renderProps) => (
renderProps.emit("press")} class={[ "json-render-list-item", renderProps.props.completed && "json-render-list-item--done", ] .filter(Boolean) .join(" ")} >
{renderProps.props.completed ? "✓" : ""}
{renderProps.props.title}
), RendererBadge: (renderProps) => ( {renderProps.props.renderer === "vue" ? "Rendered with Vue" : renderProps.props.renderer === "react" ? "Rendered with React" : renderProps.props.renderer === "svelte" ? "Rendered with Svelte" : "Rendered with Solid"} ), RendererTabs: (renderProps) => (
Render
), }; ================================================ FILE: examples/vite-renderers/src/spec.ts ================================================ import type { Spec } from "@json-render/core"; export const demoSpec: Spec = { root: "root", state: { renderer: "vue", count: 0, todos: [ { id: 1, title: "Learn JSON Render", completed: true }, { id: 2, title: "Try @json-render/vue, @json-render/react, @json-render/svelte, and @json-render/solid", completed: false, }, { id: 3, title: "Build something awesome", completed: false }, ], }, elements: { root: { type: "Stack", props: { gap: 24, padding: 24, direction: "vertical" }, children: [ "demo-title", "renderer-tabs", "renderer-badge", "counter-card", "milestone-badge", "todos-card", ], }, "demo-title": { type: "Text", props: { content: "@json-render multi-renderer demo", size: "xl", weight: "bold", }, }, "renderer-badge": { type: "RendererBadge", props: { renderer: { $state: "/renderer" } }, }, "renderer-tabs": { type: "RendererTabs", props: { renderer: { $state: "/renderer" } }, on: { pressVue: { action: "switchToVue" }, pressReact: { action: "switchToReact" }, pressSvelte: { action: "switchToSvelte" }, pressSolid: { action: "switchToSolid" }, }, }, // ---- Counter card ---- "counter-card": { type: "Card", props: { title: "Counter", subtitle: "Click the buttons to change the count", }, children: ["counter-body"], }, "counter-body": { type: "Stack", props: { gap: 12, direction: "horizontal", align: "center" }, children: [ "decrement-btn", "counter-value", "increment-btn", "reset-btn", ], }, "decrement-btn": { type: "Button", props: { label: "−", variant: "secondary" }, on: { press: { action: "decrement" } }, }, "counter-value": { type: "Text", props: { content: { $state: "/count" }, size: "xl", weight: "bold", }, }, "increment-btn": { type: "Button", props: { label: "+", variant: "primary" }, on: { press: { action: "increment" } }, }, "reset-btn": { type: "Button", props: { label: "Reset", variant: "danger" }, on: { press: { action: "reset" } }, }, // ---- Milestone badge (visible only when count >= 10) ---- "milestone-badge": { type: "Badge", props: { label: "Milestone reached: 10!", color: "#10b981" }, visible: { $state: "/count", gte: 10 }, }, // ---- Todos card ---- "todos-card": { type: "Card", props: { title: "Todo List", subtitle: "Your tasks" }, children: ["todos-list"], }, "todos-list": { type: "Stack", props: { gap: 8, direction: "vertical" }, repeat: { statePath: "/todos", key: "id" }, children: ["todo-item"], }, "todo-item": { type: "ListItem", props: { title: { $item: "title" }, completed: { $item: "completed" }, }, on: { press: { action: "toggleItem", params: { index: { $index: true } } }, }, }, }, }; ================================================ FILE: examples/vite-renderers/src/svelte/App.svelte ================================================
================================================ FILE: examples/vite-renderers/src/svelte/DemoRenderer.svelte ================================================ ================================================ FILE: examples/vite-renderers/src/svelte/catalog.ts ================================================ import { schema } from "@json-render/svelte/schema"; import { catalogDef } from "../shared/catalog-def"; export const catalog = schema.createCatalog(catalogDef); ================================================ FILE: examples/vite-renderers/src/svelte/components/Badge.svelte ================================================ {props.label} ================================================ FILE: examples/vite-renderers/src/svelte/components/Button.svelte ================================================ ================================================ FILE: examples/vite-renderers/src/svelte/components/Card.svelte ================================================
{#if props.title}

{props.title}

{/if} {#if props.subtitle}

{props.subtitle}

{/if} {#if children} {@render children()} {/if}
================================================ FILE: examples/vite-renderers/src/svelte/components/Input.svelte ================================================ ================================================ FILE: examples/vite-renderers/src/svelte/components/ListItem.svelte ================================================ ================================================ FILE: examples/vite-renderers/src/svelte/components/RendererBadge.svelte ================================================ {props.renderer === "vue" ? "Rendered with Vue" : props.renderer === "react" ? "Rendered with React" : props.renderer === "svelte" ? "Rendered with Svelte" : "Rendered with Solid"} ================================================ FILE: examples/vite-renderers/src/svelte/components/RendererTabs.svelte ================================================
Render
================================================ FILE: examples/vite-renderers/src/svelte/components/Stack.svelte ================================================
{#if children} {@render children()} {/if}
================================================ FILE: examples/vite-renderers/src/svelte/components/Text.svelte ================================================ {String(props.content ?? "")} ================================================ FILE: examples/vite-renderers/src/svelte/mount.ts ================================================ import { mount as mountComponent, unmount as unmountComponent } from "svelte"; import type { Spec } from "@json-render/core"; import App from "./App.svelte"; let app: ReturnType | null = null; export function mount(container: HTMLElement, renderer: string, spec: Spec) { app = mountComponent(App, { target: container, props: { initialRenderer: renderer, spec }, }); } export function unmount() { if (app) { unmountComponent(app); app = null; } } ================================================ FILE: examples/vite-renderers/src/svelte/registry.ts ================================================ import { defineRegistry, type ComponentRegistry } from "@json-render/svelte"; import { catalog } from "./catalog"; import { actionStubs } from "../shared/handlers"; import Stack from "./components/Stack.svelte"; import Card from "./components/Card.svelte"; import Text from "./components/Text.svelte"; import Button from "./components/Button.svelte"; import Badge from "./components/Badge.svelte"; import ListItem from "./components/ListItem.svelte"; import RendererBadge from "./components/RendererBadge.svelte"; import RendererTabs from "./components/RendererTabs.svelte"; const components: ComponentRegistry = { Stack, Card, Text, Button, Badge, ListItem, RendererBadge, RendererTabs, }; export const { registry } = defineRegistry(catalog, { components, actions: actionStubs, }); ================================================ FILE: examples/vite-renderers/src/vue/App.vue ================================================ ================================================ FILE: examples/vite-renderers/src/vue/DemoRenderer.vue ================================================ ================================================ FILE: examples/vite-renderers/src/vue/catalog.ts ================================================ import { schema } from "@json-render/vue/schema"; import { catalogDef } from "../shared/catalog-def"; export const catalog = schema.createCatalog(catalogDef); export type AppCatalog = typeof catalog; ================================================ FILE: examples/vite-renderers/src/vue/mount.ts ================================================ import { createApp, type App } from "vue"; import type { Spec } from "@json-render/core"; import VueApp from "./App.vue"; let app: App | null = null; export function mount(container: HTMLElement, renderer: string, spec: Spec) { app = createApp(VueApp, { initialRenderer: renderer, spec }); app.mount(container); } export function unmount() { app?.unmount(); app = null; } ================================================ FILE: examples/vite-renderers/src/vue/registry.ts ================================================ import { h } from "vue"; import type { Components } from "@json-render/vue"; import type { AppCatalog } from "./catalog"; export const components: Components = { Stack: ({ props, children }) => h( "div", { class: [ "json-render-stack", props.direction === "horizontal" && "json-render-stack--horizontal", props.align && `json-render-stack--align-${props.align}`, ] .filter(Boolean) .join(" "), style: { gap: props.gap ? `${props.gap}px` : undefined, padding: props.padding ? `${props.padding}px` : undefined, }, }, children, ), Card: ({ props, children }) => h("div", { class: "json-render-card" }, [ props.title && h("div", { class: "json-render-card-title-wrap" }, [ h("h2", { class: "json-render-card-title" }, props.title), ]), props.subtitle && h("p", { class: "json-render-card-subtitle" }, props.subtitle), children, ]), Text: ({ props }) => h( "span", { class: [ "json-render-text", props.size && props.size !== "md" && `json-render-text--${props.size}`, props.weight && props.weight !== "normal" && `json-render-text--${props.weight}`, ] .filter(Boolean) .join(" "), style: props.color ? { color: props.color } : undefined, }, String(props.content ?? ""), ), Button: ({ props, emit }) => h( "button", { disabled: props.disabled, onClick: () => emit("press"), class: [ "json-render-button", props.variant && `json-render-button--${props.variant}`, ] .filter(Boolean) .join(" "), }, props.label, ), Badge: ({ props }) => h( "span", { class: "json-render-badge", style: props.color ? { backgroundColor: `${props.color}20`, color: props.color, borderColor: `${props.color}40`, } : undefined, }, props.label, ), ListItem: ({ props, emit }) => h( "div", { onClick: () => emit("press"), class: [ "json-render-list-item", props.completed && "json-render-list-item--done", ] .filter(Boolean) .join(" "), }, [ h( "div", { class: [ "json-render-list-item-check", props.completed && "json-render-list-item-check--done", ] .filter(Boolean) .join(" "), }, props.completed ? "✓" : "", ), h( "span", { class: [ "json-render-list-item-text", props.completed && "json-render-list-item-text--done", ] .filter(Boolean) .join(" "), }, props.title, ), ], ), RendererBadge: ({ props }) => h("span", { class: "json-render-renderer-badge" }, [ h("span", { class: "json-render-renderer-dot" }), props.renderer === "vue" ? "Rendered with Vue" : props.renderer === "react" ? "Rendered with React" : props.renderer === "svelte" ? "Rendered with Svelte" : "Rendered with Solid", ]), RendererTabs: ({ props, emit }) => h("div", { class: "json-render-renderer-tabs-wrapper" }, [ h("span", { class: "json-render-renderer-tabs-label" }, "Render"), h("div", { class: "json-render-renderer-tabs" }, [ h( "button", { onClick: () => emit("pressVue"), class: [ "json-render-renderer-tab", props.renderer === "vue" && "json-render-renderer-tab--active", ] .filter(Boolean) .join(" "), }, "Vue", ), h( "button", { onClick: () => emit("pressReact"), class: [ "json-render-renderer-tab", props.renderer === "react" && "json-render-renderer-tab--active", ] .filter(Boolean) .join(" "), }, "React", ), h( "button", { onClick: () => emit("pressSvelte"), class: [ "json-render-renderer-tab", props.renderer === "svelte" && "json-render-renderer-tab--active", ] .filter(Boolean) .join(" "), }, "Svelte", ), h( "button", { onClick: () => emit("pressSolid"), class: [ "json-render-renderer-tab", props.renderer === "solid" && "json-render-renderer-tab--active", ] .filter(Boolean) .join(" "), }, "Solid", ), ]), ]), }; ================================================ FILE: examples/vite-renderers/svelte.config.js ================================================ export default {}; ================================================ FILE: examples/vite-renderers/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", "strict": true }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.svelte"] } ================================================ FILE: examples/vite-renderers/vite.config.ts ================================================ import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import react from "@vitejs/plugin-react"; import { svelte } from "@sveltejs/vite-plugin-svelte"; import solid from "vite-plugin-solid"; export default defineConfig({ plugins: [ svelte(), vue(), react({ include: /src\/react\/.*\.tsx$/ }), solid({ include: [/src\/solid\/.*\.tsx$/, /packages\/solid\/.*\.tsx$/] }), ], }); ================================================ FILE: examples/vue/CHANGELOG.md ================================================ # example-vue ## 0.1.7 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 - @json-render/vue@0.14.1 ## 0.1.6 ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 - @json-render/vue@0.14.0 ## 0.1.5 ### Patch Changes - Updated dependencies [5b32de8] - @json-render/core@0.13.0 - @json-render/vue@0.13.0 ## 0.1.4 ### Patch Changes - Updated dependencies [54a1ecf] - @json-render/core@0.12.1 - @json-render/vue@0.12.1 ## 0.1.3 ### Patch Changes - Updated dependencies [63c339b] - @json-render/core@0.12.0 - @json-render/vue@0.12.0 ## 0.1.2 ### Patch Changes - Updated dependencies [3f1e71e] - @json-render/core@0.11.0 - @json-render/vue@0.11.0 ## 0.1.1 ### Patch Changes - Updated dependencies [9cef4e9] - @json-render/core@0.10.0 - @json-render/vue@0.10.0 ================================================ FILE: examples/vue/index.html ================================================ json-render vue example
================================================ FILE: examples/vue/package.json ================================================ { "name": "example-vue", "version": "0.1.7", "private": true, "scripts": { "predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)", "dev": "portless vue.json-render vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "@json-render/core": "workspace:*", "@json-render/vue": "workspace:*", "vue": "^3.5.0", "zod": "4.3.6" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.4", "typescript": "^5.9.3", "vite": "^7.3.1", "vue-tsc": "^3.2.5" } } ================================================ FILE: examples/vue/src/App.vue ================================================ ================================================ FILE: examples/vue/src/DemoRenderer.vue ================================================ ================================================ FILE: examples/vue/src/lib/catalog.ts ================================================ import { schema } from "@json-render/vue/schema"; import { z } from "zod"; export const catalog = schema.createCatalog({ components: { Stack: { props: z.object({ gap: z.number().optional(), padding: z.number().optional(), direction: z.enum(["vertical", "horizontal"]).optional(), align: z.enum(["start", "center", "end"]).optional(), }), slots: ["default"], description: "Layout container that stacks children vertically or horizontally", }, Card: { props: z.object({ title: z.string().optional(), subtitle: z.string().optional(), }), slots: ["default"], description: "A card container with optional title and subtitle", }, Text: { props: z.object({ content: z.string(), size: z.enum(["sm", "md", "lg", "xl"]).optional(), weight: z.enum(["normal", "medium", "bold"]).optional(), color: z.string().optional(), }), slots: [], description: "Displays a text string", }, Button: { props: z.object({ label: z.string(), variant: z.enum(["primary", "secondary", "danger"]).optional(), disabled: z.boolean().optional(), }), slots: [], description: "A clickable button that emits a 'press' event", }, Badge: { props: z.object({ label: z.string(), color: z.string().optional(), }), slots: [], description: "A small badge/tag label", }, ListItem: { props: z.object({ title: z.string(), description: z.string().optional(), completed: z.boolean().optional(), }), slots: [], description: "A single item in a list", }, Input: { props: z.object({ value: z.string().optional(), placeholder: z.string().optional(), }), slots: [], description: "A text input field that supports two-way state binding", }, }, actions: { increment: { params: z.object({}), description: "Increment the counter by 1", }, decrement: { params: z.object({}), description: "Decrement the counter by 1", }, reset: { params: z.object({}), description: "Reset the counter to 0", }, toggleItem: { params: z.object({ index: z.number(), }), description: "Toggle the completed state of a todo item", }, }, }); export type AppCatalog = typeof catalog; ================================================ FILE: examples/vue/src/lib/registry.ts ================================================ import { h } from "vue"; import type { Components } from "@json-render/vue"; import { useBoundProp } from "@json-render/vue"; import type { AppCatalog } from "./catalog"; export const components: Components = { Stack: ({ props, children }) => { const isHorizontal = props.direction === "horizontal"; return h( "div", { style: { display: "flex", flexDirection: isHorizontal ? "row" : "column", gap: props.gap ? `${props.gap}px` : undefined, padding: props.padding ? `${props.padding}px` : undefined, alignItems: props.align ?? (isHorizontal ? "center" : "stretch"), }, }, children, ); }, Card: ({ props, children }) => h( "div", { style: { backgroundColor: "white", borderRadius: "12px", border: "1px solid #e5e7eb", padding: "20px", boxShadow: "0 1px 3px rgba(0,0,0,0.05)", }, }, [ props.title && h("div", { style: { marginBottom: "4px" } }, [ h( "h2", { style: { fontSize: "16px", fontWeight: "600", color: "#111827", margin: 0, }, }, props.title, ), ]), props.subtitle && h( "p", { style: { fontSize: "13px", color: "#6b7280", margin: "0 0 12px 0", }, }, props.subtitle, ), children, ], ), Text: ({ props }) => { const sizeMap: Record = { sm: "12px", md: "14px", lg: "16px", xl: "24px", }; const weightMap: Record = { normal: "400", medium: "500", bold: "700", }; return h( "span", { style: { fontSize: sizeMap[props.size ?? "md"] ?? "14px", fontWeight: weightMap[props.weight ?? "normal"] ?? "400", color: props.color ?? "#111827", }, }, String(props.content ?? ""), ); }, Button: ({ props, emit }) => h( "button", { disabled: props.disabled, onClick: () => emit("press"), style: { padding: "8px 16px", borderRadius: "8px", border: "none", cursor: props.disabled ? "not-allowed" : "pointer", fontWeight: "500", fontSize: "14px", transition: "background 0.15s", opacity: props.disabled ? "0.5" : "1", backgroundColor: props.variant === "danger" ? "#fee2e2" : props.variant === "secondary" ? "#f3f4f6" : "#3b82f6", color: props.variant === "danger" ? "#dc2626" : props.variant === "secondary" ? "#374151" : "white", }, }, props.label, ), Badge: ({ props }) => h( "span", { style: { display: "inline-block", padding: "4px 12px", borderRadius: "999px", fontSize: "13px", fontWeight: "500", backgroundColor: props.color ? `${props.color}20` : "#e0f2fe", color: props.color ?? "#0369a1", border: `1px solid ${props.color ? `${props.color}40` : "#bae6fd"}`, }, }, props.label, ), Input: ({ props, bindings }) => { const [value, setValue] = useBoundProp( props.value as string | undefined, bindings?.value, ); return h("input", { value: value ?? "", placeholder: props.placeholder as string | undefined, onInput: (e: Event) => setValue((e.target as HTMLInputElement).value), style: { padding: "8px 12px", borderRadius: "8px", border: "1px solid #d1d5db", fontSize: "14px", outline: "none", width: "100%", boxSizing: "border-box", }, }); }, ListItem: ({ props, emit }) => h( "div", { onClick: () => emit("press"), style: { display: "flex", alignItems: "center", gap: "12px", padding: "10px 12px", borderRadius: "8px", cursor: "pointer", backgroundColor: props.completed ? "#f0fdf4" : "#f9fafb", border: `1px solid ${props.completed ? "#bbf7d0" : "#e5e7eb"}`, }, }, [ h( "div", { style: { width: "18px", height: "18px", borderRadius: "50%", border: `2px solid ${props.completed ? "#16a34a" : "#d1d5db"}`, backgroundColor: props.completed ? "#16a34a" : "transparent", flexShrink: "0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "11px", color: "white", }, }, props.completed ? "✓" : "", ), h( "span", { style: { fontSize: "14px", color: props.completed ? "#6b7280" : "#111827", textDecoration: props.completed ? "line-through" : "none", }, }, props.title, ), ], ), }; ================================================ FILE: examples/vue/src/lib/spec.ts ================================================ import type { Spec } from "@json-render/core"; export const demoSpec: Spec = { root: "root", state: { count: 0, name: "", todos: [ { id: 1, title: "Learn Vue 3", completed: true }, { id: 2, title: "Try @json-render/vue", completed: false }, { id: 3, title: "Build something awesome", completed: false }, ], }, elements: { root: { type: "Stack", props: { gap: 24, padding: 24, direction: "vertical" }, children: [ "header", "counter-card", "milestone-badge", "todos-card", "input-card", ], }, header: { type: "Text", props: { content: "@json-render/vue demo", size: "xl", weight: "bold", }, }, // ---- Counter card ---- "counter-card": { type: "Card", props: { title: "Counter", subtitle: "Click the buttons to change the count", }, children: ["counter-body"], }, "counter-body": { type: "Stack", props: { gap: 12, direction: "horizontal", align: "center" }, children: [ "decrement-btn", "counter-value", "increment-btn", "reset-btn", ], }, "decrement-btn": { type: "Button", props: { label: "−", variant: "secondary" }, on: { press: { action: "decrement" } }, }, "counter-value": { type: "Text", props: { content: { $state: "/count" }, size: "xl", weight: "bold", }, }, "increment-btn": { type: "Button", props: { label: "+", variant: "primary" }, on: { press: { action: "increment" } }, }, "reset-btn": { type: "Button", props: { label: "Reset", variant: "danger" }, on: { press: { action: "reset" } }, }, // ---- Milestone badge (visible only when count >= 10) ---- "milestone-badge": { type: "Badge", props: { label: "Milestone reached: 10!", color: "#10b981" }, visible: { $state: "/count", gte: 10 }, }, // ---- Todos card ---- "todos-card": { type: "Card", props: { title: "Todo List", subtitle: "Your tasks" }, children: ["todos-list"], }, "todos-list": { type: "Stack", props: { gap: 8, direction: "vertical" }, repeat: { statePath: "/todos", key: "id" }, children: ["todo-item"], }, "todo-item": { type: "ListItem", props: { title: { $item: "title" }, completed: { $item: "completed" }, }, on: { press: { action: "toggleItem", params: { index: { $index: true } } }, }, }, // ---- Bound Input card (useBoundProp demo) ---- "input-card": { type: "Card", props: { title: "Bound Input", subtitle: "Type to update state — the display text reacts in real time", }, children: ["input-body"], }, "input-body": { type: "Stack", props: { gap: 12, direction: "vertical" }, children: ["name-input", "name-display"], }, "name-input": { type: "Input", props: { value: { $bindState: "/name" }, placeholder: "Enter your name…", }, }, "name-display": { type: "Text", props: { content: { $state: "/name" }, size: "md", color: "#6b7280", }, }, }, }; ================================================ FILE: examples/vue/src/main.ts ================================================ import { createApp } from "vue"; import App from "./App.vue"; createApp(App).mount("#app"); ================================================ FILE: examples/vue/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "jsx": "preserve", "strict": true }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] } ================================================ FILE: examples/vue/vite.config.ts ================================================ import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; export default defineConfig({ plugins: [vue()], }); ================================================ FILE: package.json ================================================ { "name": "json-render", "private": true, "license": "Apache-2.0", "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git" }, "homepage": "https://github.com/vercel-labs/json-render#readme", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "scripts": { "build": "turbo run build", "predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)", "dev": "turbo run dev --concurrency 20", "lint": "turbo run lint", "format": "prettier --write \"**/*.{ts,tsx}\"", "type-check": "turbo run check-types", "check-types": "turbo run check-types", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "pnpm --filter e2e-tests test", "prepare": "husky", "changeset": "changeset", "ci:version": "changeset version && pnpm install --no-frozen-lockfile", "ci:publish": "pnpm run build && changeset publish", "generate:og": "npx tsx scripts/generate-og-images.mts" }, "devDependencies": { "@changesets/cli": "2.29.8", "@resvg/resvg-js": "2.6.2", "@solidjs/testing-library": "^0.8.10", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.1", "@testing-library/svelte": "^5.2.0", "@types/react": "^19.2.3", "husky": "^9.1.7", "jsdom": "^27.4.0", "lint-staged": "^16.2.7", "prettier": "^3.7.4", "react": "^19.2.4", "react-dom": "^19.2.4", "satori": "0.25.0", "solid-js": "^1.9.11", "svelte": "^5.0.0", "turbo": "^2.7.4", "typescript": "5.9.2", "vite-plugin-solid": "^2.11.10", "vitest": "^4.0.17" }, "packageManager": "pnpm@10.29.3", "engines": { "node": ">=18" }, "lint-staged": { "*.{ts,tsx}": "prettier --write" }, "workspaces": [ "apps/*", "examples/*", "examples/stripe-app/*", "packages/*", "tests/*" ] } ================================================ FILE: packages/codegen/CHANGELOG.md ================================================ # @json-render/codegen ## 0.14.1 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 ## 0.14.0 ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 ## 0.13.0 ### Patch Changes - Updated dependencies [5b32de8] - @json-render/core@0.13.0 ## 0.12.1 ### Patch Changes - Updated dependencies [54a1ecf] - @json-render/core@0.12.1 ## 0.12.0 ### Patch Changes - Updated dependencies [63c339b] - @json-render/core@0.12.0 ## 0.11.0 ### Patch Changes - Updated dependencies [3f1e71e] - @json-render/core@0.11.0 ## 0.10.0 ### Patch Changes - Updated dependencies [9cef4e9] - @json-render/core@0.10.0 ## 0.9.1 ### Patch Changes - @json-render/core@0.9.1 ## 0.9.0 ### Minor Changes - 1d755c1: External state store, store adapters, and bug fixes. ### New: External State Store The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode. - Added `StateStore` interface and `createStateStore()` factory to `@json-render/core` - `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop for controlled mode - When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored) - When `store` is omitted, everything works exactly as before (fully backward compatible) - Applied across all platform packages: react, react-native, react-pdf - Store utilities (`createStoreAdapter`, `immutableSetByPath`, `flattenToPointers`) available via `@json-render/core/store-utils` for building custom adapters ### New: Store Adapter Packages - `@json-render/zustand` — Zustand adapter for `StateStore` - `@json-render/redux` — Redux / Redux Toolkit adapter for `StateStore` - `@json-render/jotai` — Jotai adapter for `StateStore` ### Changed: `onStateChange` signature updated (breaking) The `onStateChange` callback now receives a single array of changed entries instead of being called once per path: ```ts // Before onStateChange?: (path: string, value: unknown) => void // After onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void ``` ### Fixed - Fix schema import to use server-safe `@json-render/react/schema` subpath, avoiding `createContext` crashes in Next.js App Router API routes - Fix chaining actions in `@json-render/react`, `@json-render/react-native`, and `@json-render/react-pdf` - Fix safely resolving inner type for Zod arrays in core schema ### Patch Changes - Updated dependencies [1d755c1] - @json-render/core@0.9.0 ## 0.8.0 ### Patch Changes - Updated dependencies [09376db] - @json-render/core@0.8.0 ## 0.7.0 ### Patch Changes - Updated dependencies [2d70fab] - @json-render/core@0.7.0 ## 0.6.1 ### Patch Changes - @json-render/core@0.6.1 ## 0.6.1 ### Patch Changes - Updated dependencies [ea97aff] - @json-render/core@0.6.1 ## 0.6.0 ### Minor Changes - 06b8745: Chat mode (inline GenUI), AI SDK integration, two-way binding, and expression-based visibility/props. ### New: Chat Mode (Inline GenUI) Two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets AI respond conversationally with embedded UI specs — ideal for chatbots and copilot experiences. - `catalog.prompt({ mode: "chat" })` generates a chat-aware system prompt - `pipeJsonRender()` server-side transform separates text from JSONL patches in a mixed stream - `createJsonRenderTransform()` low-level TransformStream for custom pipelines ### New: AI SDK Integration First-class Vercel AI SDK support with typed data parts and stream utilities. - `SpecDataPart` type for `data-spec` stream parts (patch, flat, nested payloads) - `SPEC_DATA_PART` / `SPEC_DATA_PART_TYPE` constants for type-safe part filtering - `createMixedStreamParser()` for parsing mixed text + JSONL streams ### New: React Chat Hooks - `useChatUI()` — full chat hook with message history, streaming, and spec extraction - `useJsonRenderMessage()` — extract spec + text from a message's parts array - `buildSpecFromParts()` / `getTextFromParts()` — utilities for working with AI SDK message parts - `useBoundProp()` — two-way binding hook for `$bindState` / `$bindItem` expressions ### New: Two-Way Binding Props can now use `$bindState` and `$bindItem` expressions for two-way data binding. The renderer resolves bindings and passes a `bindings` map to components, enabling write-back to state. ### New: Expression-Based Props and Visibility Replaced string token rewriting with structured expression objects: - Props: `{ $state: "/path" }`, `{ $item: "field" }`, `{ $index: true }` - Visibility: `{ $state: "/path", eq: "value" }`, `{ $item: "active" }`, `{ $index: true, gt: 0 }` - Logic: `{ $and: [...] }`, `{ $or: [...] }`, and implicit AND via arrays - Comparison operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not` ### New: Utilities - `applySpecPatch()` — typed convenience wrapper for applying a single patch to a Spec - `nestedToFlat()` — convert nested tree specs to flat `{ root, elements }` format - `resolveBindings()` / `resolveActionParam()` — resolve binding paths and action params ### New: Chat Example Full-featured chat example (`examples/chat`) with AI agent, tool calls (crypto, GitHub, Hacker News, weather, search), theme toggle, and streaming UI generation. ### Improved: Renderer - `ElementRenderer` is now `React.memo`'d for better performance in repeat lists - `emit` is always defined (never `undefined`) — no more optional chaining needed - Action params are resolved through `resolveActionParam` supporting `$item`, `$index`, `$state` - Repeat scope now passes the actual item object instead of requiring token rewriting ### Breaking Changes - **Expressions renamed**: `{ $path }` / `{ path }` replaced by `{ $state }`, `{ $item }`, `{ $index }` - **Visibility conditions**: `{ path }` → `{ $state }`, `{ and/or/not }` → `{ $and/$or }` with `not` as operator flag - **DynamicValue**: `{ path: string }` → `{ $state: string }` - **Repeat field**: `repeat.path` → `repeat.statePath` - **Action params**: `path` → `statePath` in setState action params - **Provider props**: `actionHandlers` → `handlers` on `JSONUIProvider`/`ActionProvider` - **Auth removed**: `AuthState` type and `{ auth }` visibility conditions removed — model auth as regular state - **Legacy catalog removed**: `createCatalog`, `generateCatalogPrompt`, `generateSystemPrompt`, `ComponentDefinition`, `CatalogConfig`, `SystemPromptOptions` removed - **React exports removed**: `createRendererFromCatalog`, `rewriteRepeatTokens` - **Codegen**: `traverseTree` → `traverseSpec`, `SpecVisitor` → `TreeVisitor` ### Patch Changes - Updated dependencies [06b8745] - @json-render/core@0.6.0 ## 0.5.2 ### Patch Changes - 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section. - Updated dependencies [429e456] - @json-render/core@0.5.2 ## 0.5.1 ### Patch Changes - @json-render/core@0.5.1 ## 0.5.0 ### Minor Changes - 3d2d1ad: Add @json-render/react-native package, event system (emit replaces onAction), repeat/list rendering, user prompt builder, spec validation, and rename DataProvider to StateProvider. ### Patch Changes - Updated dependencies [3d2d1ad] - @json-render/core@0.5.0 ## 0.4.4 ### Patch Changes - dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream - Updated dependencies [dd17549] - @json-render/core@0.4.4 ## 0.4.3 ### Patch Changes - 61ee8e5: include remove op in system prompt - Updated dependencies [61ee8e5] - @json-render/core@0.4.3 ## 0.4.2 ### Patch Changes - 54bce09: add defineRegistry function - Updated dependencies [54bce09] - @json-render/core@0.4.2 ================================================ FILE: packages/codegen/README.md ================================================ # @json-render/codegen Utilities for generating code from json-render UI trees. This package provides framework-agnostic utilities for building code generators. Use these utilities to create custom code exporters for your specific framework (Next.js, Remix, etc.). ## Installation ```bash npm install @json-render/codegen # or pnpm add @json-render/codegen ``` ## Utilities ### Tree Traversal ```typescript import { traverseSpec, collectUsedComponents, collectStatePaths, collectActions } from '@json-render/codegen'; // Walk the spec depth-first traverseSpec(spec, (element, key, depth, parent) => { console.log(`${' '.repeat(depth * 2)}${key}: ${element.type}`); }); // Get all component types used const components = collectUsedComponents(spec); // Set { 'Card', 'Metric', 'Button' } // Get all state paths referenced const statePaths = collectStatePaths(spec); // Set { 'analytics/revenue', 'user/name' } // Get all action names const actions = collectActions(spec); // Set { 'submit_form', 'refresh_data' } ``` ### Serialization ```typescript import { serializePropValue, serializeProps, escapeString } from '@json-render/codegen'; // Serialize a single value serializePropValue("hello"); // { value: '"hello"', needsBraces: false } serializePropValue(42); // { value: '42', needsBraces: true } serializePropValue({ $state: '/user/name' }); // { value: '{ $state: "/user/name" }', needsBraces: true } // Serialize props for JSX serializeProps({ title: "Dashboard", columns: 3, disabled: true }); // 'title="Dashboard" columns={3} disabled' ``` ### Types ```typescript import type { GeneratedFile, CodeGenerator } from '@json-render/codegen'; // Implement your own code generator const myGenerator: CodeGenerator = { generate(spec) { return [ { path: 'package.json', content: '...' }, { path: 'app/page.tsx', content: '...' }, ]; } }; ``` ## Building a Custom Generator See the `examples/dashboard` for a complete example of building a Next.js code generator using these utilities. ```typescript import { collectUsedComponents, collectStatePaths, traverseSpec, serializeProps, type GeneratedFile } from '@json-render/codegen'; import type { Spec } from '@json-render/core'; export function generateNextJSProject(spec: Spec): GeneratedFile[] { const files: GeneratedFile[] = []; const components = collectUsedComponents(spec); // Generate package.json files.push({ path: 'package.json', content: JSON.stringify({ name: 'my-generated-app', dependencies: { next: '^14.0.0', react: '^18.0.0', } }, null, 2) }); // Generate component files... // Generate main page... return files; } ``` ## License Apache-2.0 ================================================ FILE: packages/codegen/package.json ================================================ { "name": "@json-render/codegen", "version": "0.14.1", "license": "Apache-2.0", "description": "Utilities for generating code from json-render UI trees", "keywords": [ "json", "ui", "codegen", "code-generation", "export" ], "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", "directory": "packages/codegen" }, "homepage": "https://json-render.dev", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "publishConfig": { "access": "public" }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } }, "files": [ "dist" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit" }, "dependencies": { "@json-render/core": "workspace:*" }, "devDependencies": { "@internal/typescript-config": "workspace:*", "tsup": "^8.0.2", "typescript": "^5.4.5" } } ================================================ FILE: packages/codegen/src/index.ts ================================================ export { traverseSpec, collectUsedComponents, collectStatePaths, collectActions, type TreeVisitor, } from "./traverse"; export { serializePropValue, serializeProps, escapeString, type SerializeOptions, } from "./serialize"; export type { GeneratedFile, CodeGenerator } from "./types"; ================================================ FILE: packages/codegen/src/serialize.ts ================================================ /** * Options for serialization */ export interface SerializeOptions { /** Quote style for strings */ quotes?: "single" | "double"; /** Indent for objects/arrays */ indent?: number; } const DEFAULT_OPTIONS: Required = { quotes: "double", indent: 2, }; /** * Escape a string for use in code */ export function escapeString( str: string, quotes: "single" | "double" = "double", ): string { const quoteChar = quotes === "single" ? "'" : '"'; const escaped = str .replace(/\\/g, "\\\\") .replace(/\n/g, "\\n") .replace(/\r/g, "\\r") .replace(/\t/g, "\\t"); if (quotes === "single") { return escaped.replace(/'/g, "\\'"); } return escaped.replace(/"/g, '\\"'); } /** * Serialize a single prop value to a code string * * @returns Object with `value` (the serialized string) and `needsBraces` (whether JSX needs {}) */ export function serializePropValue( value: unknown, options: SerializeOptions = {}, ): { value: string; needsBraces: boolean } { const opts = { ...DEFAULT_OPTIONS, ...options }; const q = opts.quotes === "single" ? "'" : '"'; if (value === null) { return { value: "null", needsBraces: true }; } if (value === undefined) { return { value: "undefined", needsBraces: true }; } if (typeof value === "string") { return { value: `${q}${escapeString(value, opts.quotes)}${q}`, needsBraces: false, }; } if (typeof value === "number") { return { value: String(value), needsBraces: true }; } if (typeof value === "boolean") { if (value === true) { return { value: "true", needsBraces: false }; // Can use shorthand } return { value: "false", needsBraces: true }; } if (Array.isArray(value)) { const items = value.map((v) => serializePropValue(v, opts).value); return { value: `[${items.join(", ")}]`, needsBraces: true }; } if (typeof value === "object") { // Check for $state reference if ( "$state" in value && typeof (value as { $state: unknown }).$state === "string" ) { return { value: `{ $state: ${q}${escapeString((value as { $state: string }).$state, opts.quotes)}${q} }`, needsBraces: true, }; } const entries = Object.entries(value) .filter(([, v]) => v !== undefined) .map(([k, v]) => { const serialized = serializePropValue(v, opts).value; // Use shorthand if key matches value for simple identifiers return `${k}: ${serialized}`; }); return { value: `{ ${entries.join(", ")} }`, needsBraces: true }; } return { value: String(value), needsBraces: true }; } /** * Serialize props object to JSX attributes string */ export function serializeProps( props: Record, options: SerializeOptions = {}, ): string { const parts: string[] = []; for (const [key, value] of Object.entries(props)) { if (value === undefined || value === null) continue; const serialized = serializePropValue(value, options); // Boolean true can be shorthand if (typeof value === "boolean" && value === true) { parts.push(key); } else if (serialized.needsBraces) { parts.push(`${key}={${serialized.value}}`); } else { parts.push(`${key}=${serialized.value}`); } } return parts.join(" "); } ================================================ FILE: packages/codegen/src/traverse.test.ts ================================================ import { describe, it, expect } from "vitest"; import { traverseSpec, collectUsedComponents, collectStatePaths, collectActions, } from "./traverse"; import type { Spec } from "@json-render/core"; describe("traverseSpec", () => { it("visits all elements depth-first", () => { const spec: Spec = { root: "root", elements: { root: { type: "Card", props: {}, children: ["child1", "child2"], }, child1: { type: "Text", props: {}, }, child2: { type: "Button", props: {}, }, }, }; const visited: string[] = []; traverseSpec(spec, (_element, key) => { visited.push(key); }); expect(visited).toEqual(["root", "child1", "child2"]); }); it("handles empty spec", () => { const visited: string[] = []; traverseSpec(null as unknown as Spec, (_element, key) => { visited.push(key); }); expect(visited).toEqual([]); }); }); describe("collectUsedComponents", () => { it("collects unique component types", () => { const spec: Spec = { root: "root", elements: { root: { type: "Card", props: {}, children: ["child1", "child2"], }, child1: { type: "Text", props: {}, }, child2: { type: "Text", props: {}, }, }, }; const components = collectUsedComponents(spec); expect(components).toEqual(new Set(["Card", "Text"])); }); }); describe("collectStatePaths", () => { it("collects paths from statePath props", () => { const spec: Spec = { root: "root", elements: { root: { type: "Metric", props: { statePath: "analytics/revenue" }, }, }, }; const paths = collectStatePaths(spec); expect(paths).toEqual(new Set(["analytics/revenue"])); }); it("collects paths from dynamic value objects", () => { const spec: Spec = { root: "root", elements: { root: { type: "Text", props: { content: { $state: "/user/name" } }, }, }, }; const paths = collectStatePaths(spec); expect(paths).toEqual(new Set(["/user/name"])); }); }); describe("collectActions", () => { it("collects action names from props", () => { const spec: Spec = { root: "root", elements: { root: { type: "Button", props: { action: "submit_form" }, }, }, }; const actions = collectActions(spec); expect(actions).toEqual(new Set(["submit_form"])); }); it("collects actions from on event bindings", () => { const spec: Spec = { root: "root", elements: { root: { type: "Button", props: {}, on: { press: { action: "submitForm" } }, }, }, }; const actions = collectActions(spec); expect(actions).toEqual(new Set(["submitForm"])); }); it("collects actions from on array bindings", () => { const spec: Spec = { root: "root", elements: { root: { type: "Button", props: {}, on: { press: [{ action: "save" }, { action: "navigate" }], }, }, }, }; const actions = collectActions(spec); expect(actions).toEqual(new Set(["save", "navigate"])); }); it("collects actions from both props and on", () => { const spec: Spec = { root: "root", elements: { root: { type: "Button", props: { action: "submit_form" }, on: { press: { action: "setState", params: { statePath: "/x" } } }, }, }, }; const actions = collectActions(spec); expect(actions).toEqual(new Set(["submit_form", "setState"])); }); }); ================================================ FILE: packages/codegen/src/traverse.ts ================================================ import type { Spec, UIElement } from "@json-render/core"; /** * Visitor function for spec traversal */ export interface TreeVisitor { ( element: UIElement, key: string, depth: number, parent: UIElement | null, ): void; } /** * Traverse a UI spec depth-first */ export function traverseSpec( spec: Spec, visitor: TreeVisitor, startKey?: string, ): void { if (!spec || !spec.root) return; const rootKey = startKey ?? spec.root; const rootElement = spec.elements[rootKey]; if (!rootElement) return; function visit(key: string, depth: number, parent: UIElement | null): void { const element = spec.elements[key]; if (!element) return; visitor(element, key, depth, parent); if (element.children) { for (const childKey of element.children) { visit(childKey, depth + 1, element); } } } visit(rootKey, 0, null); } /** * Collect all unique component types used in a spec */ export function collectUsedComponents(spec: Spec): Set { const components = new Set(); traverseSpec(spec, (element, _key) => { components.add(element.type); }); return components; } /** * Collect all state paths referenced in a spec */ export function collectStatePaths(spec: Spec): Set { const paths = new Set(); traverseSpec(spec, (element, _key) => { // Check props for data paths for (const [propName, propValue] of Object.entries(element.props)) { // Check for path props (e.g., statePath, dataPath, bindPath) if (typeof propValue === "string") { if ( propName.endsWith("Path") || propName === "bindPath" || propName === "statePath" ) { paths.add(propValue); } } // Check for dynamic value objects with $state if ( propValue && typeof propValue === "object" && "$state" in propValue && typeof (propValue as { $state: unknown }).$state === "string" ) { paths.add((propValue as { $state: string }).$state); } } // Check visibility conditions for $state paths if (element.visible != null && typeof element.visible !== "boolean") { collectPathsFromCondition(element.visible, paths); } }); return paths; } function collectPathFromItem( item: Record, paths: Set, ): void { if (typeof item.$state === "string") { paths.add(item.$state); } // Also collect $state references in comparison values (eq, neq, etc.) for (const op of ["eq", "neq", "gt", "gte", "lt", "lte"]) { const val = item[op]; if ( val && typeof val === "object" && "$state" in (val as Record) && typeof (val as Record).$state === "string" ) { paths.add((val as { $state: string }).$state); } } } function collectPathsFromCondition( condition: unknown, paths: Set, ): void { if (!condition || typeof condition !== "object") return; // Array = implicit AND if (Array.isArray(condition)) { for (const item of condition) { if (item && typeof item === "object") { collectPathFromItem(item as Record, paths); } } return; } const cond = condition as Record; // $or: recurse into each child condition if ("$or" in cond && Array.isArray(cond.$or)) { for (const child of cond.$or) { collectPathsFromCondition(child, paths); } return; } // Single StateCondition collectPathFromItem(cond, paths); } /** * Collect all action names used in a spec */ export function collectActions(spec: Spec): Set { const actions = new Set(); traverseSpec(spec, (element, _key) => { for (const propValue of Object.values(element.props)) { // Check for action prop (string action name) if (typeof propValue === "string" && propValue.startsWith("action:")) { actions.add(propValue.slice(7)); } // Check for action objects if ( propValue && typeof propValue === "object" && "name" in propValue && typeof (propValue as { name: unknown }).name === "string" ) { actions.add((propValue as { name: string }).name); } } // Also check direct action prop const actionProp = element.props.action; if (typeof actionProp === "string") { actions.add(actionProp); } // Collect actions from on event bindings const onBindings = element.on; if (onBindings) { for (const binding of Object.values(onBindings)) { const bindings = Array.isArray(binding) ? binding : [binding]; for (const b of bindings) { if ( b && typeof b === "object" && "action" in b && typeof (b as { action: unknown }).action === "string" ) { actions.add((b as { action: string }).action); } } } } }); return actions; } ================================================ FILE: packages/codegen/src/types.ts ================================================ import type { Spec } from "@json-render/core"; /** * Represents a generated file */ export interface GeneratedFile { /** File path relative to project root */ path: string; /** File contents */ content: string; } /** * Interface for code generators */ export interface CodeGenerator { /** Generate files from a UI spec */ generate(spec: Spec): GeneratedFile[]; } ================================================ FILE: packages/codegen/tsconfig.json ================================================ { "extends": "@internal/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/codegen/tsup.config.ts ================================================ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts"], format: ["cjs", "esm"], dts: true, sourcemap: true, clean: true, external: ["@json-render/core"], }); ================================================ FILE: packages/core/CHANGELOG.md ================================================ # @json-render/core ## 0.14.1 ### Patch Changes - 43b7515: Add yaml format support to `buildUserPrompt` ### New: - `buildUserPrompt` now accepts `format` and `serializer` options, enabling YAML as a wire format alongside JSON ## 0.14.0 ### Minor Changes - a8afd8b: Add YAML wire format package and universal edit modes for surgical spec refinement. ### New: - **`@json-render/yaml`** -- YAML wire format for json-render. Includes streaming YAML parser, `yamlPrompt()` for system prompts, and AI SDK transform (`pipeYamlRender`) as a drop-in alternative to JSONL streaming. Supports four fence types: `yaml-spec`, `yaml-edit`, `yaml-patch`, and `diff`. - **Universal edit modes** in `@json-render/core` -- three strategies for multi-turn spec refinement: `patch` (RFC 6902), `merge` (RFC 7396), and `diff` (unified diff). New `editModes` option on `buildUserPrompt()` and `PromptOptions`. New helpers: `deepMergeSpec()`, `diffToPatches()`, `buildEditUserPrompt()`, `buildEditInstructions()`, `isNonEmptySpec()`. ### Improved: - **Playground** -- format toggle (JSONL / YAML), edit mode picker (patch / merge / diff), and token usage display with prompt caching stats. - **Prompt caching** -- generate API uses Anthropic ephemeral cache control for system prompts. - **CI** -- lint, type-check, and test jobs now run in parallel. ## 0.13.0 ### Minor Changes - 5b32de8: Add SolidJS and React Three Fiber renderers, plus strict JSON Schema mode for LLM structured outputs. ### New: - **`@json-render/solid`** -- SolidJS renderer. JSON becomes Solid components with reactive rendering, schema export, and full catalog support. - **`@json-render/react-three-fiber`** -- React Three Fiber renderer. JSON becomes 3D scenes with 19 built-in components for meshes, lights, models, environments, text, cameras, and controls. ### Improved: - **`@json-render/core`** -- `jsonSchema({ strict: true })` produces a JSON Schema subset compatible with LLM structured output APIs (OpenAI, Google Gemini, Anthropic). Ensures `additionalProperties: false` on every object and all properties listed in `required`. ## 0.12.1 ### Patch Changes - 54a1ecf: Rename generation modes and fix MCP React duplicate module error. ### Changed: - **`@json-render/core`** — Renamed generation modes from `"generate"` / `"chat"` to `"standalone"` / `"inline"`. The old names still work but emit a deprecation warning. ### Fixed: - **`@json-render/mcp`** — Resolved React duplicate module error (`useRef` returning null) by adding `resolve.dedupe` Vite configuration. Added `./build-app-html` export entry point. ### Other: - Updated `homepage` URLs across all packages to point to `https://json-render.dev`. - Reorganized skills directory structure for cleaner naming. - Added skills documentation page to the web app. ## 0.12.0 ### Minor Changes - 63c339b: Add Svelte renderer, React Email renderer, and MCP Apps integration. ### New: - **`@json-render/svelte`** — Svelte 5 renderer with runes-based reactivity. Full support for data binding, visibility, actions, validation, watchers, streaming, and repeat scopes. Includes `defineRegistry`, `Renderer`, `schema`, composables, and context providers. - **`@json-render/react-email`** — React Email renderer for generating HTML and plain-text emails from JSON specs. 17 standard components (Html, Head, Body, Container, Section, Row, Column, Heading, Text, Link, Button, Image, Hr, Preview, Markdown). Server-side `renderToHtml` / `renderToPlainText` APIs. Custom catalog and registry support. - **`@json-render/mcp`** — MCP Apps integration that serves json-render UIs as interactive apps inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients. `createMcpApp` server factory, `useJsonRenderApp` React hook for iframes, and `buildAppHtml` utility. ### Fixed: - **`@json-render/svelte`** — Corrected JSDoc comment and added missing `zod` peer dependency. ## 0.11.0 ### Minor Changes - 3f1e71e: Image renderer: generate SVG and PNG from JSON specs. ### New: `@json-render/image` Package Server-side image renderer powered by Satori. Turns the same `{ root, elements }` spec format into SVG or PNG output for OG images, social cards, and banners. - `renderToSvg(spec, options)` — render spec to SVG string - `renderToPng(spec, options)` — render spec to PNG buffer (requires `@resvg/resvg-js`) - 9 standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer - `standardComponentDefinitions` catalog for AI prompt generation - Server-safe import path: `@json-render/image/server` - Sub-path exports: `/render`, `/catalog`, `/server` ## 0.10.0 ### Minor Changes - 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values. ### New: `@json-render/vue` Package Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming. - `defineRegistry` — create type-safe component registries from catalogs - `Renderer` — render specs as Vue component trees - Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider` - Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation` - Streaming: `useUIStream`, `useChatUI` - External store support via `StateStore` interface ### New: `@json-render/xstate` Package XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend. - `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom - Requires `@xstate/store` v3+ ### New: `$computed` Expressions Call registered functions from prop expressions: - `{ "$computed": "functionName", "args": { "key": } }` — calls a named function with resolved args - Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer` - `ComputedFunction` type exported from `@json-render/core` ### New: `$template` Expressions Interpolate state values into strings: - `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values - Missing paths resolve to empty string ### New: State Watchers React to state changes by triggering actions: - `watch` field on elements maps state paths to action bindings - Fires when watched values change (not on initial render) - Supports cascading dependencies (e.g. country → city loading) - `watch` is a top-level field on elements (sibling of type/props/children), not inside props - Spec validator detects and auto-fixes `watch` placed inside props ### New: Cross-Field Validation Functions New built-in validation functions for cross-field comparisons: - `equalTo` — alias for `matches` with clearer semantics - `lessThan` — value must be less than another field (numbers, strings, coerced) - `greaterThan` — value must be greater than another field - `requiredIf` — required only when a condition field is truthy - Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling ### New: `validateForm` Action (React) Built-in action that validates all registered form fields at once: - Runs `validateAll()` synchronously and writes `{ valid, errors }` to state - Default state path: `/formValidation` (configurable via `statePath` param) - Added to React schema's built-in actions list ### Improved: shadcn/ui Validation All form components now support validation: - Checkbox, Radio, Switch — added `checks` and `validateOn` props - Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit) - Shared validation schemas reduce catalog definition duplication ### Improved: React Provider Tree Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access. ## 0.9.1 ## 0.9.0 ### Minor Changes - 1d755c1: External state store, store adapters, and bug fixes. ### New: External State Store The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode. - Added `StateStore` interface and `createStateStore()` factory to `@json-render/core` - `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop for controlled mode - When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored) - When `store` is omitted, everything works exactly as before (fully backward compatible) - Applied across all platform packages: react, react-native, react-pdf - Store utilities (`createStoreAdapter`, `immutableSetByPath`, `flattenToPointers`) available via `@json-render/core/store-utils` for building custom adapters ### New: Store Adapter Packages - `@json-render/zustand` — Zustand adapter for `StateStore` - `@json-render/redux` — Redux / Redux Toolkit adapter for `StateStore` - `@json-render/jotai` — Jotai adapter for `StateStore` ### Changed: `onStateChange` signature updated (breaking) The `onStateChange` callback now receives a single array of changed entries instead of being called once per path: ```ts // Before onStateChange?: (path: string, value: unknown) => void // After onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void ``` ### Fixed - Fix schema import to use server-safe `@json-render/react/schema` subpath, avoiding `createContext` crashes in Next.js App Router API routes - Fix chaining actions in `@json-render/react`, `@json-render/react-native`, and `@json-render/react-pdf` - Fix safely resolving inner type for Zod arrays in core schema ## 0.8.0 ### Minor Changes - 09376db: New `@json-render/react-pdf` package for generating PDF documents from JSON specs. ### New: `@json-render/react-pdf` PDF renderer for json-render, powered by `@react-pdf/renderer`. Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI. - `renderToBuffer(spec)` — render a spec to an in-memory PDF buffer - `renderToStream(spec)` — render to a readable stream (pipe to HTTP response) - `renderToFile(spec, path)` — render directly to a file on disk - `defineRegistry` / `createRenderer` — same API as `@json-render/react` for custom components - `standardComponentDefinitions` — Zod-based catalog definitions (server-safe via `@json-render/react-pdf/catalog`) - `standardComponents` — React PDF implementations for all standard components - Server-safe import via `@json-render/react-pdf/server` Standard components: - **Document structure**: Document, Page - **Layout**: View, Row, Column - **Content**: Heading, Text, Image, Link - **Data**: Table, List - **Decorative**: Divider, Spacer - **Page-level**: PageNumber Includes full context support: state management, visibility conditions, actions, validation, and repeat scopes — matching the capabilities of `@json-render/react`. ## 0.7.0 ### Minor Changes - 2d70fab: New `@json-render/shadcn` package, event handles, built-in actions, and stream improvements. ### New: `@json-render/shadcn` Package Pre-built [shadcn/ui](https://ui.shadcn.com/) component library for json-render. 30+ components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`. - `shadcnComponentDefinitions` — Zod-based catalog definitions for all components (server-safe, no React dependency via `@json-render/shadcn/catalog`) - `shadcnComponents` — React implementations for all components - Layout: Card, Stack, Grid, Separator - Navigation: Tabs, Accordion, Collapsible, Pagination - Overlay: Dialog, Drawer, Tooltip, Popover, DropdownMenu - Content: Heading, Text, Image, Avatar, Badge, Alert, Carousel, Table - Feedback: Progress, Skeleton, Spinner - Input: Button, Link, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Toggle, ToggleGroup, ButtonGroup ### New: Event Handles (`on()`) Components now receive an `on(event)` function in addition to `emit(event)`. The `on()` function returns an `EventHandle` with metadata: - `emit()` — fire the event - `shouldPreventDefault` — whether any action binding requested `preventDefault` - `bound` — whether any handler is bound to this event ### New: `BaseComponentProps` Catalog-agnostic base type for component render functions. Use when building reusable component libraries (like `@json-render/shadcn`) that are not tied to a specific catalog. ### New: Built-in Actions in Schema Schemas can now declare `builtInActions` — actions that are always available at runtime and automatically injected into prompts. The React schema declares `setState`, `pushState`, and `removeState` as built-in, so they appear in prompts without needing to be listed in catalog `actions`. ### New: `preventDefault` on `ActionBinding` Action bindings now support a `preventDefault` boolean field, allowing the LLM to request that default browser behavior (e.g. navigation on links) be prevented. ### Improved: Stream Transform Text Block Splitting `createJsonRenderTransform()` now properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs. This ensures the AI SDK creates separate text parts, preserving correct interleaving of prose and UI in `message.parts`. ### Improved: `defineRegistry` Actions Requirement `defineRegistry` now conditionally requires the `actions` field only when the catalog declares actions. Catalogs with no actions (e.g. `actions: {}`) no longer need to pass an empty actions object. ## 0.6.1 ## 0.6.1 ### Patch Changes - ea97aff: Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at the same empty path with different `checks` configs, causing them to overwrite each other endlessly. Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update. ## 0.6.0 ### Minor Changes - 06b8745: Chat mode (inline GenUI), AI SDK integration, two-way binding, and expression-based visibility/props. ### New: Chat Mode (Inline GenUI) Two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets AI respond conversationally with embedded UI specs — ideal for chatbots and copilot experiences. - `catalog.prompt({ mode: "chat" })` generates a chat-aware system prompt - `pipeJsonRender()` server-side transform separates text from JSONL patches in a mixed stream - `createJsonRenderTransform()` low-level TransformStream for custom pipelines ### New: AI SDK Integration First-class Vercel AI SDK support with typed data parts and stream utilities. - `SpecDataPart` type for `data-spec` stream parts (patch, flat, nested payloads) - `SPEC_DATA_PART` / `SPEC_DATA_PART_TYPE` constants for type-safe part filtering - `createMixedStreamParser()` for parsing mixed text + JSONL streams ### New: React Chat Hooks - `useChatUI()` — full chat hook with message history, streaming, and spec extraction - `useJsonRenderMessage()` — extract spec + text from a message's parts array - `buildSpecFromParts()` / `getTextFromParts()` — utilities for working with AI SDK message parts - `useBoundProp()` — two-way binding hook for `$bindState` / `$bindItem` expressions ### New: Two-Way Binding Props can now use `$bindState` and `$bindItem` expressions for two-way data binding. The renderer resolves bindings and passes a `bindings` map to components, enabling write-back to state. ### New: Expression-Based Props and Visibility Replaced string token rewriting with structured expression objects: - Props: `{ $state: "/path" }`, `{ $item: "field" }`, `{ $index: true }` - Visibility: `{ $state: "/path", eq: "value" }`, `{ $item: "active" }`, `{ $index: true, gt: 0 }` - Logic: `{ $and: [...] }`, `{ $or: [...] }`, and implicit AND via arrays - Comparison operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not` ### New: Utilities - `applySpecPatch()` — typed convenience wrapper for applying a single patch to a Spec - `nestedToFlat()` — convert nested tree specs to flat `{ root, elements }` format - `resolveBindings()` / `resolveActionParam()` — resolve binding paths and action params ### New: Chat Example Full-featured chat example (`examples/chat`) with AI agent, tool calls (crypto, GitHub, Hacker News, weather, search), theme toggle, and streaming UI generation. ### Improved: Renderer - `ElementRenderer` is now `React.memo`'d for better performance in repeat lists - `emit` is always defined (never `undefined`) — no more optional chaining needed - Action params are resolved through `resolveActionParam` supporting `$item`, `$index`, `$state` - Repeat scope now passes the actual item object instead of requiring token rewriting ### Breaking Changes - **Expressions renamed**: `{ $path }` / `{ path }` replaced by `{ $state }`, `{ $item }`, `{ $index }` - **Visibility conditions**: `{ path }` → `{ $state }`, `{ and/or/not }` → `{ $and/$or }` with `not` as operator flag - **DynamicValue**: `{ path: string }` → `{ $state: string }` - **Repeat field**: `repeat.path` → `repeat.statePath` - **Action params**: `path` → `statePath` in setState action params - **Provider props**: `actionHandlers` → `handlers` on `JSONUIProvider`/`ActionProvider` - **Auth removed**: `AuthState` type and `{ auth }` visibility conditions removed — model auth as regular state - **Legacy catalog removed**: `createCatalog`, `generateCatalogPrompt`, `generateSystemPrompt`, `ComponentDefinition`, `CatalogConfig`, `SystemPromptOptions` removed - **React exports removed**: `createRendererFromCatalog`, `rewriteRepeatTokens` - **Codegen**: `traverseTree` → `traverseSpec`, `SpecVisitor` → `TreeVisitor` ## 0.5.2 ### Patch Changes - 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section. ## 0.5.1 ## 0.5.0 ### Minor Changes - 3d2d1ad: Add @json-render/react-native package, event system (emit replaces onAction), repeat/list rendering, user prompt builder, spec validation, and rename DataProvider to StateProvider. ## 0.4.4 ### Patch Changes - dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream ## 0.4.3 ### Patch Changes - 61ee8e5: include remove op in system prompt ## 0.4.2 ### Patch Changes - 54bce09: add defineRegistry function ================================================ FILE: packages/core/README.md ================================================ # @json-render/core Core library for json-render. Define schemas, create catalogs, generate AI prompts, and stream specs. ## Installation ```bash npm install @json-render/core zod ``` ## Key Concepts - **Schema**: Defines the structure of specs and catalogs - **Catalog**: Maps component/action names to their definitions with Zod props - **Spec**: JSON output from AI that conforms to the schema - **SpecStream**: JSONL streaming format for progressive spec building ## Quick Start ### Define a Schema ```typescript import { defineSchema } from "@json-render/core"; export const schema = defineSchema((s) => ({ spec: s.object({ root: s.object({ type: s.ref("catalog.components"), props: s.propsOf("catalog.components"), children: s.array(s.string()), // Element keys (flat spec format) }), }), catalog: s.object({ components: s.map({ props: s.zod(), description: s.string(), }), actions: s.map({ description: s.string(), }), }), }), { promptTemplate: myPromptTemplate, // Optional custom AI prompt generator }); ``` ### Create a Catalog ```typescript import { defineCatalog } from "@json-render/core"; import { schema } from "./schema"; import { z } from "zod"; export const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string(), subtitle: z.string().nullable(), }), description: "A card container with title", }, Button: { props: z.object({ label: z.string(), variant: z.enum(["primary", "secondary"]).nullable(), }), description: "A clickable button", }, }, actions: { submit: { description: "Submit the form" }, cancel: { description: "Cancel and close" }, }, }); ``` ### Generate AI Prompts ```typescript // Generate system prompt for AI const systemPrompt = catalog.prompt(); // With custom rules const systemPrompt = catalog.prompt({ system: "You are a dashboard builder.", customRules: [ "Always include a header", "Use Card components for grouping", ], }); ``` ### Stream AI Responses (SpecStream) The SpecStream format uses JSONL patches to progressively build specs: ```typescript import { createSpecStreamCompiler } from "@json-render/core"; // Create a compiler for your spec type const compiler = createSpecStreamCompiler(); // Process streaming chunks from AI while (streaming) { const chunk = await reader.read(); const { result, newPatches } = compiler.push(chunk); if (newPatches.length > 0) { // Update UI with partial result setSpec(result); } } // Get final compiled result const finalSpec = compiler.getResult(); ``` SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations (each line is a patch): ```jsonl {"op":"add","path":"/root","value":"card-1"} {"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Hello"},"children":["btn-1"]}} {"op":"add","path":"/elements/btn-1","value":{"type":"Button","props":{"label":"Click"},"children":[]}} ``` All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`. ### Low-Level Utilities ```typescript import { parseSpecStreamLine, applySpecStreamPatch, compileSpecStream, } from "@json-render/core"; // Parse a single line const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}'); // { op: "add", path: "/root", value: {} } // Apply a patch to an object const obj = {}; applySpecStreamPatch(obj, patch); // obj is now { root: {} } // Compile entire JSONL string at once const spec = compileSpecStream(jsonlString); ``` ## API Reference ### Schema | Export | Purpose | |--------|---------| | `defineSchema(builder, options?)` | Create a schema with spec/catalog structure | | `SchemaBuilder` | Builder with `s.object()`, `s.array()`, `s.map()`, etc. | Schema options: | Option | Purpose | |--------|---------| | `promptTemplate` | Custom AI prompt generator | | `defaultRules` | Default rules injected before custom rules in prompts | | `builtInActions` | Actions always available at runtime, auto-injected into prompts (e.g. `setState`) | ### Catalog | Export | Purpose | |--------|---------| | `defineCatalog(schema, data)` | Create a type-safe catalog from schema | | `catalog.prompt(options?)` | Generate AI system prompt | ### SpecStream | Export | Purpose | |--------|---------| | `createSpecStreamCompiler()` | Create streaming compiler | | `parseSpecStreamLine(line)` | Parse single JSONL line | | `applySpecStreamPatch(obj, patch)` | Apply patch to object | | `compileSpecStream(jsonl)` | Compile entire JSONL string | ### Dynamic Props | Export | Purpose | |--------|---------| | `resolvePropValue(value, ctx)` | Resolve a single prop expression | | `resolveElementProps(props, ctx)` | Resolve all prop expressions in an element | | `PropExpression` | Type for prop values that may contain expressions | | `ComputedFunction` | Function signature for `$computed` expressions | | `PropResolutionContext` | Context for resolving props (includes `functions` for `$computed`) | ### Validation | Export | Purpose | |--------|---------| | `check.required()` | Required validation helper | | `check.email()` | Email validation helper | | `check.matches(path)` | Cross-field match helper | | `check.equalTo(path)` | Cross-field equality helper | | `check.lessThan(path)` | Cross-field less-than helper | | `check.greaterThan(path)` | Cross-field greater-than helper | | `check.requiredIf(path)` | Conditional required helper | | `builtInValidationFunctions` | All built-in validation functions | | `runValidationCheck()` | Run a single validation check | ### User Prompt | Export | Purpose | |--------|---------| | `buildUserPrompt(options)` | Build a user prompt with optional spec refinement and state context | | `buildEditUserPrompt(options)` | Build a user prompt for editing an existing spec (used internally by `buildUserPrompt`) | | `buildEditInstructions(config, format)` | Generate the prompt section describing available edit modes | | `isNonEmptySpec(spec)` | Check whether a spec has a root and at least one element | | `UserPromptOptions` | Options type for `buildUserPrompt` | | `EditMode` | `"patch" \| "merge" \| "diff"` | | `EditConfig` | Configuration for edit modes (`{ modes: EditMode[] }`) | | `BuildEditUserPromptOptions` | Options type for `buildEditUserPrompt` | ### Merge and Diff | Export | Purpose | |--------|---------| | `deepMergeSpec(base, patch)` | RFC 7396 deep merge (null deletes, arrays replace, objects recurse) | | `diffToPatches(oldObj, newObj)` | Generate RFC 6902 JSON Patch operations from object diff | ### Spec Validation | Export | Purpose | |--------|---------| | `validateSpec(spec, options?)` | Validate spec structure and return issues | | `autoFixSpec(spec)` | Auto-fix common spec issues (returns corrected copy) | | `formatSpecIssues(issues)` | Format validation issues as readable strings | ### Actions | Export | Purpose | |--------|---------| | `ActionBinding` | Action binding with `action`, `params`, `confirm`, `preventDefault`, etc. | | `BuiltInAction` | Built-in action definition with `name` and `description` | ### Inline Mode (Mixed Streams) | Export | Purpose | |--------|---------| | `createJsonRenderTransform()` | TransformStream that separates text from JSONL patches in a mixed stream | | `pipeJsonRender()` | Server-side helper to pipe a mixed stream through the transform | | `SPEC_DATA_PART` / `SPEC_DATA_PART_TYPE` | Constants for filtering spec data parts | The transform splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`. ### State Store | Export | Purpose | |--------|---------| | `createStateStore(initialState?)` | Create a framework-agnostic in-memory `StateStore` | | `StateStore` | Interface for plugging in external state management (Redux, Zustand, XState, etc.) | | `StateModel` | State model type (`Record`) | The `StateStore` interface allows renderers to use external state management instead of the built-in internal store: ```typescript import { createStateStore, type StateStore } from "@json-render/core"; // Simple in-memory store const store = createStateStore({ count: 0 }); store.get("/count"); // 0 store.set("/count", 1); // updates and notifies subscribers store.getSnapshot(); // { count: 1 } // Subscribe to changes (compatible with React's useSyncExternalStore) const unsubscribe = store.subscribe(() => { console.log("state changed:", store.getSnapshot()); }); ``` Pass the store to `StateProvider` in any renderer package (`@json-render/react`, `@json-render/react-native`, `@json-render/react-pdf`) for controlled mode. ### Store Utilities (for adapter authors) Available via `@json-render/core/store-utils`: | Export | Purpose | |--------|---------| | `createStoreAdapter(config)` | Build a full `StateStore` from a minimal `{ getSnapshot, setSnapshot, subscribe }` config | | `immutableSetByPath(root, path, value)` | Immutably set a value at a JSON Pointer path with structural sharing | | `flattenToPointers(obj)` | Flatten a nested object into JSON Pointer keyed entries | | `StoreAdapterConfig` | Config type for `createStoreAdapter` | ```typescript import { createStoreAdapter, immutableSetByPath, flattenToPointers } from "@json-render/core/store-utils"; ``` `createStoreAdapter` handles `get`, `set` (with no-op detection), batched `update`, `getSnapshot`, `getServerSnapshot`, and `subscribe` -- adapter authors only need to supply the snapshot source, write API, and subscribe mechanism: ```typescript import { createStoreAdapter } from "@json-render/core/store-utils"; const store = createStoreAdapter({ getSnapshot: () => myLib.getState(), setSnapshot: (next) => myLib.setState(next), subscribe: (listener) => myLib.subscribe(listener), }); ``` The official adapter packages (`@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`) are all built on top of `createStoreAdapter`. ### Types | Export | Purpose | |--------|---------| | `Spec` | Base spec type | | `Catalog` | Catalog type | | `BuiltInAction` | Built-in action type (`name` + `description`) | | `ComputedFunction` | Function signature for `$computed` expressions | | `VisibilityCondition` | Visibility condition type (used by `$cond`) | | `VisibilityContext` | Context for evaluating visibility and prop expressions | | `SpecStreamLine` | Single patch operation | | `SpecStreamCompiler` | Streaming compiler interface | ## Dynamic Prop Expressions Any prop value can be a dynamic expression that resolves based on data state at render time. Expressions are resolved by the renderer before props reach components. ### Data Binding (`$state`) Read a value directly from the state model: ```json { "color": { "$state": "/theme/primary" }, "label": { "$state": "/user/name" } } ``` ### Two-Way Binding (`$bindState` / `$bindItem`) Use `{ "$bindState": "/path" }` on the natural value prop for form components that need read/write access. The component reads from and writes to the state path: ```json { "type": "Input", "props": { "value": { "$bindState": "/form/email" }, "placeholder": "Email" } } ``` Inside a repeat scope, use `{ "$bindItem": "completed" }` to bind to a field on the current item: ### Conditional (`$cond` / `$then` / `$else`) Evaluate a condition (same syntax as visibility conditions) and pick a value: ```json { "color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }, "name": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "home", "$else": "home-outline" } } ``` `$then` and `$else` can themselves be expressions (recursive): ```json { "label": { "$cond": { "$state": "/user/isAdmin" }, "$then": { "$state": "/admin/greeting" }, "$else": "Welcome" } } ``` ### Repeat Item (`$item`) Inside children of a repeated element, read a field from the current array item: ```json { "$item": "title" } ``` Use `""` to get the entire item object. `$item` takes a path string because items are typically objects with nested fields to navigate. ### Repeat Index (`$index`) Get the current array index inside a repeat: ```json { "$index": true } ``` `$index` uses `true` as a sentinel flag because the index is a scalar value with no sub-path to navigate (unlike `$item` which needs a path). ### Template (`$template`) Interpolate state values into strings using `${/path}` syntax: ```json { "label": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." } } ``` Missing paths resolve to an empty string. ### Computed (`$computed`) Call a registered function with resolved arguments: ```json { "text": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } } } ``` Functions are registered in the catalog and provided at runtime via the `functions` prop on the renderer. ```typescript import type { ComputedFunction } from "@json-render/core"; const functions: Record = { fullName: (args) => `${args.first} ${args.last}`, }; ``` ### API ```typescript import { resolvePropValue, resolveElementProps } from "@json-render/core"; // Resolve a single value const color = resolvePropValue( { $cond: { $state: "/active", eq: "yes" }, $then: "blue", $else: "gray" }, { stateModel: myState } ); // Resolve all props on an element const resolved = resolveElementProps(element.props, { stateModel: myState }); ``` ## Visibility Conditions Visibility conditions control when elements are shown. `VisibilityContext` is `{ stateModel: StateModel, repeatItem?: unknown, repeatIndex?: number }`. ### Syntax ```typescript { "$state": "/path" } // truthiness { "$state": "/path", "not": true } // falsy { "$state": "/path", "eq": value } // equality { "$state": "/path", "neq": value } // inequality { "$state": "/path", "gt": number } // greater than { "$item": "field" } // repeat item field { "$index": true, "gt": 0 } // repeat index [ condition, condition ] // implicit AND { "$and": [ condition, condition ] } // explicit AND { "$or": [ condition, condition ] } // OR true / false // always / never ``` ### TypeScript Helpers ```typescript import { visibility } from "@json-render/core"; visibility.always // true visibility.never // false visibility.when("/path") // { $state: "/path" } visibility.unless("/path") // { $state: "/path", not: true } visibility.eq("/path", val) // { $state: "/path", eq: val } visibility.neq("/path", val) // { $state: "/path", neq: val } visibility.gt("/path", n) // { $state: "/path", gt: n } visibility.gte("/path", n) // { $state: "/path", gte: n } visibility.lt("/path", n) // { $state: "/path", lt: n } visibility.lte("/path", n) // { $state: "/path", lte: n } visibility.and(cond1, cond2) // { $and: [cond1, cond2] } visibility.or(cond1, cond2) // { $or: [cond1, cond2] } ``` ## User Prompt Builder Build structured user prompts for AI generation, with support for refinement and state context: ```typescript import { buildUserPrompt } from "@json-render/core"; // Fresh generation const prompt = buildUserPrompt({ prompt: "create a todo app" }); // Refinement with edit modes (default: patch-only) const refinementPrompt = buildUserPrompt({ prompt: "add a dark mode toggle", currentSpec: existingSpec, editModes: ["patch", "merge"], }); // With runtime state context const contextPrompt = buildUserPrompt({ prompt: "show my data", state: { todos: [{ text: "Buy milk" }] }, }); ``` When `currentSpec` is provided, the prompt instructs the AI to use the specified edit modes. Available modes: - **`"patch"`** — RFC 6902 JSON Patch. One operation per line. Best for precise, targeted single-field updates. - **`"merge"`** — RFC 7396 JSON Merge Patch. Partial object deep-merged; `null` deletes. Best for structural changes. - **`"diff"`** — Unified diff against the serialized spec. Best for small text-level changes. ## Deep Merge and Diff Format-agnostic utilities for working with specs: ```typescript import { deepMergeSpec, diffToPatches } from "@json-render/core"; // RFC 7396 deep merge: null deletes, arrays replace, objects recurse const merged = deepMergeSpec(baseSpec, { elements: { main: { props: { title: "New" } } } }); // RFC 6902 diff: generate JSON Patch operations from two objects const patches = diffToPatches(oldSpec, newSpec); // [{ op: "replace", path: "/elements/main/props/title", value: "New" }] ``` ## Spec Validation Validate spec structure and auto-fix common issues: ```typescript import { validateSpec, autoFixSpec, formatSpecIssues } from "@json-render/core"; // Validate a spec const { valid, issues } = validateSpec(spec); // Format issues for display console.log(formatSpecIssues(issues)); // Auto-fix common issues (returns a corrected copy) const fixed = autoFixSpec(spec); ``` ## State Watchers Elements can declare a `watch` field to trigger actions when state values change. `watch` is a top-level field on the element (sibling of `type`, `props`, `children`), not inside `props`. ```json { "type": "Select", "props": { "label": "Country", "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] }, "watch": { "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } } }, "children": [] } ``` Watchers only fire on value changes, not on initial render. Multiple action bindings per path execute sequentially. ## Validation ### Built-in Validation Functions | Function | Description | Args | |----------|-------------|------| | `required` | Value must not be empty | — | | `email` | Must be a valid email | — | | `url` | Must be a valid URL | — | | `numeric` | Must be a number | — | | `minLength` | Minimum string length | `{ min: number }` | | `maxLength` | Maximum string length | `{ max: number }` | | `min` | Minimum numeric value | `{ min: number }` | | `max` | Maximum numeric value | `{ max: number }` | | `pattern` | Must match regex | `{ pattern: string }` | | `matches` | Must equal another field | `{ other: { $state: "/path" } }` | | `equalTo` | Alias for matches | `{ other: { $state: "/path" } }` | | `lessThan` | Must be less than another field | `{ other: { $state: "/path" } }` | | `greaterThan` | Must be greater than another field | `{ other: { $state: "/path" } }` | | `requiredIf` | Required when condition is truthy | `{ field: { $state: "/path" } }` | ### TypeScript Helpers ```typescript import { check } from "@json-render/core"; check.required("Field is required"); check.email("Invalid email"); check.matches("/form/password", "Passwords must match"); check.equalTo("/form/password", "Passwords must match"); check.lessThan("/form/endDate", "Must be before end date"); check.greaterThan("/form/startDate", "Must be after start date"); check.requiredIf("/form/enableNotifications", "Required when notifications enabled"); ``` ## Custom Schemas json-render supports completely different spec formats for different renderers: ```typescript // React: Flat element map { root: "card-1", elements: { "card-1": { type: "Card", props: {...}, children: [...] } } } // Remotion: Timeline { composition: {...}, tracks: [...], clips: [...] } // Your own: Whatever you need { pages: [...], navigation: {...}, theme: {...} } ``` Each renderer defines its own schema with `defineSchema()` and its own prompt template. ================================================ FILE: packages/core/package.json ================================================ { "name": "@json-render/core", "version": "0.14.1", "license": "Apache-2.0", "description": "JSON becomes real things. Define your catalog, register your components, let AI generate.", "keywords": [ "json", "ui", "react", "ai", "generative-ui", "llm", "schema", "zod", "streaming" ], "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", "directory": "packages/core" }, "homepage": "https://json-render.dev", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "publishConfig": { "access": "public" }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" }, "./store-utils": { "types": "./dist/store-utils.d.ts", "import": "./dist/store-utils.mjs", "require": "./dist/store-utils.js" } }, "files": [ "dist" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit" }, "dependencies": { "zod": "^4.3.6" }, "devDependencies": { "@internal/typescript-config": "workspace:*", "tsup": "^8.0.2", "typescript": "^5.4.5" }, "peerDependencies": { "zod": "^4.0.0" } } ================================================ FILE: packages/core/src/actions.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { resolveAction, executeAction, interpolateString, actionBinding, } from "./actions"; describe("interpolateString", () => { it("interpolates ${path} expressions", () => { const data = { user: { name: "Alice" }, count: 5 }; expect(interpolateString("Hello ${/user/name}!", data)).toBe( "Hello Alice!", ); expect(interpolateString("${/user/name} has ${/count} items", data)).toBe( "Alice has 5 items", ); }); it("returns string unchanged when no variables", () => { expect(interpolateString("No vars here", {})).toBe("No vars here"); }); it("replaces missing values with empty string", () => { expect(interpolateString("Hello ${/missing}!", {})).toBe("Hello !"); }); it("handles multiple occurrences of same variable", () => { const data = { name: "Bob" }; expect(interpolateString("${/name} says ${/name}", data)).toBe( "Bob says Bob", ); }); }); describe("resolveAction", () => { it("resolves literal params", () => { const resolved = resolveAction( { action: "navigate", params: { url: "/home", count: 5 }, }, {}, ); expect(resolved.action).toBe("navigate"); expect(resolved.params.url).toBe("/home"); expect(resolved.params.count).toBe(5); }); it("resolves dynamic $state params", () => { const data = { userId: 123, settings: { theme: "dark" } }; const resolved = resolveAction( { action: "updateUser", params: { id: { $state: "/userId" }, theme: { $state: "/settings/theme" }, }, }, data, ); expect(resolved.params.id).toBe(123); expect(resolved.params.theme).toBe("dark"); }); it("interpolates confirmation messages", () => { const data = { user: { name: "Alice" } }; const resolved = resolveAction( { action: "delete", confirm: { title: "Delete ${/user/name}", message: "Are you sure you want to delete ${/user/name}?", }, }, data, ); expect(resolved.confirm?.title).toBe("Delete Alice"); expect(resolved.confirm?.message).toBe( "Are you sure you want to delete Alice?", ); }); it("preserves onSuccess and onError handlers", () => { const resolved = resolveAction( { action: "save", onSuccess: { navigate: "/success" }, onError: { set: { error: "$error.message" } }, }, {}, ); expect(resolved.onSuccess).toEqual({ navigate: "/success" }); expect(resolved.onError).toEqual({ set: { error: "$error.message" } }); }); }); describe("executeAction", () => { it("calls the handler with resolved params", async () => { const handler = vi.fn().mockResolvedValue(undefined); await executeAction({ action: { action: "test", params: { value: 42 } }, handler, setState: vi.fn(), }); expect(handler).toHaveBeenCalledWith({ value: 42 }); }); it("handles onSuccess with navigate", async () => { const navigate = vi.fn(); await executeAction({ action: { action: "test", params: {}, onSuccess: { navigate: "/success" }, }, handler: vi.fn().mockResolvedValue(undefined), setState: vi.fn(), navigate, }); expect(navigate).toHaveBeenCalledWith("/success"); }); it("handles onSuccess with set", async () => { const setState = vi.fn(); await executeAction({ action: { action: "test", params: {}, onSuccess: { set: { saved: true, message: "Done" } }, }, handler: vi.fn().mockResolvedValue(undefined), setState, }); expect(setState).toHaveBeenCalledWith("saved", true); expect(setState).toHaveBeenCalledWith("message", "Done"); }); it("handles onSuccess with action", async () => { const executeActionFn = vi.fn(); await executeAction({ action: { action: "test", params: {}, onSuccess: { action: "followUp" }, }, handler: vi.fn().mockResolvedValue(undefined), setState: vi.fn(), executeAction: executeActionFn, }); expect(executeActionFn).toHaveBeenCalledWith("followUp"); }); it("handles onError with set", async () => { const setState = vi.fn(); const error = new Error("Something went wrong"); await executeAction({ action: { action: "test", params: {}, onError: { set: { error: "$error.message" } }, }, handler: vi.fn().mockRejectedValue(error), setState, }); expect(setState).toHaveBeenCalledWith("error", "Something went wrong"); }); it("handles onError with action", async () => { const executeActionFn = vi.fn(); const error = new Error("Failed"); await executeAction({ action: { action: "test", params: {}, onError: { action: "handleError" }, }, handler: vi.fn().mockRejectedValue(error), setState: vi.fn(), executeAction: executeActionFn, }); expect(executeActionFn).toHaveBeenCalledWith("handleError"); }); it("re-throws error when no onError handler", async () => { const error = new Error("Unhandled"); await expect( executeAction({ action: { action: "test", params: {} }, handler: vi.fn().mockRejectedValue(error), setState: vi.fn(), }), ).rejects.toThrow("Unhandled"); }); }); describe("actionBinding helper", () => { describe("simple", () => { it("creates binding with action only", () => { const a = actionBinding.simple("navigate"); expect(a.action).toBe("navigate"); expect(a.params).toBeUndefined(); }); it("creates binding with params", () => { const a = actionBinding.simple("navigate", { url: "/home" }); expect(a.action).toBe("navigate"); expect(a.params).toEqual({ url: "/home" }); }); }); describe("withConfirm", () => { it("creates binding with confirmation", () => { const a = actionBinding.withConfirm("delete", { title: "Confirm", message: "Are you sure?", }); expect(a.action).toBe("delete"); expect(a.confirm).toEqual({ title: "Confirm", message: "Are you sure?", }); }); it("creates binding with confirmation and params", () => { const a = actionBinding.withConfirm( "delete", { title: "Delete", message: "Delete item?" }, { id: 123 }, ); expect(a.action).toBe("delete"); expect(a.params).toEqual({ id: 123 }); expect(a.confirm?.title).toBe("Delete"); }); }); describe("withSuccess", () => { it("creates binding with navigate success handler", () => { const a = actionBinding.withSuccess("save", { navigate: "/success" }); expect(a.action).toBe("save"); expect(a.onSuccess).toEqual({ navigate: "/success" }); }); it("creates binding with set success handler", () => { const a = actionBinding.withSuccess("save", { set: { saved: true } }); expect(a.onSuccess).toEqual({ set: { saved: true } }); }); it("creates binding with success handler and params", () => { const a = actionBinding.withSuccess( "save", { navigate: "/done" }, { data: "test" }, ); expect(a.params).toEqual({ data: "test" }); expect(a.onSuccess).toEqual({ navigate: "/done" }); }); }); }); ================================================ FILE: packages/core/src/actions.ts ================================================ import { z } from "zod"; import type { DynamicValue, StateModel } from "./types"; import { DynamicValueSchema, resolveDynamicValue } from "./types"; /** * Confirmation dialog configuration */ export interface ActionConfirm { title: string; message: string; confirmLabel?: string; cancelLabel?: string; variant?: "default" | "danger"; } /** * Action success handler */ export type ActionOnSuccess = | { navigate: string } | { set: Record } | { action: string }; /** * Action error handler */ export type ActionOnError = | { set: Record } | { action: string }; /** * Action binding — maps an event to an action invocation. * * Used inside the `on` field of a UIElement: * ```json * { "on": { "press": { "action": "setState", "params": { "statePath": "/x", "value": 1 } } } } * ``` */ export interface ActionBinding { /** Action name (must be in catalog) */ action: string; /** Parameters to pass to the action handler */ params?: Record; /** Confirmation dialog before execution */ confirm?: ActionConfirm; /** Handler after successful execution */ onSuccess?: ActionOnSuccess; /** Handler after failed execution */ onError?: ActionOnError; /** Whether to prevent default browser behavior (e.g. navigation on links) */ preventDefault?: boolean; } /** * @deprecated Use ActionBinding instead */ export type Action = ActionBinding; /** * Schema for action confirmation */ export const ActionConfirmSchema = z.object({ title: z.string(), message: z.string(), confirmLabel: z.string().optional(), cancelLabel: z.string().optional(), variant: z.enum(["default", "danger"]).optional(), }); /** * Schema for success handlers */ export const ActionOnSuccessSchema = z.union([ z.object({ navigate: z.string() }), z.object({ set: z.record(z.string(), z.unknown()) }), z.object({ action: z.string() }), ]); /** * Schema for error handlers */ export const ActionOnErrorSchema = z.union([ z.object({ set: z.record(z.string(), z.unknown()) }), z.object({ action: z.string() }), ]); /** * Full action binding schema */ export const ActionBindingSchema = z.object({ action: z.string(), params: z.record(z.string(), DynamicValueSchema).optional(), confirm: ActionConfirmSchema.optional(), onSuccess: ActionOnSuccessSchema.optional(), onError: ActionOnErrorSchema.optional(), preventDefault: z.boolean().optional(), }); /** * @deprecated Use ActionBindingSchema instead */ export const ActionSchema = ActionBindingSchema; /** * Action handler function signature */ export type ActionHandler< TParams = Record, TResult = unknown, > = (params: TParams) => Promise | TResult; /** * Action definition in catalog */ export interface ActionDefinition> { /** Zod schema for params validation */ params?: z.ZodType; /** Description for AI */ description?: string; } /** * Resolved action with all dynamic values resolved */ export interface ResolvedAction { action: string; params: Record; confirm?: ActionConfirm; onSuccess?: ActionOnSuccess; onError?: ActionOnError; } /** * Resolve all dynamic values in an action binding */ export function resolveAction( binding: ActionBinding, stateModel: StateModel, ): ResolvedAction { const resolvedParams: Record = {}; if (binding.params) { for (const [key, value] of Object.entries(binding.params)) { resolvedParams[key] = resolveDynamicValue(value, stateModel); } } // Interpolate confirmation message if present let confirm = binding.confirm; if (confirm) { confirm = { ...confirm, message: interpolateString(confirm.message, stateModel), title: interpolateString(confirm.title, stateModel), }; } return { action: binding.action, params: resolvedParams, confirm, onSuccess: binding.onSuccess, onError: binding.onError, }; } /** * Interpolate ${path} expressions in a string */ export function interpolateString( template: string, stateModel: StateModel, ): string { return template.replace(/\$\{([^}]+)\}/g, (_, path) => { const value = resolveDynamicValue({ $state: path }, stateModel); return String(value ?? ""); }); } /** * Context for action execution */ export interface ActionExecutionContext { /** The resolved action */ action: ResolvedAction; /** The action handler from the host */ handler: ActionHandler; /** Function to update state model */ setState: (path: string, value: unknown) => void; /** Function to navigate */ navigate?: (path: string) => void; /** Function to execute another action */ executeAction?: (name: string) => Promise; } /** * Execute an action with all callbacks */ export async function executeAction( ctx: ActionExecutionContext, ): Promise { const { action, handler, setState, navigate, executeAction } = ctx; try { await handler(action.params); // Handle success if (action.onSuccess) { if ("navigate" in action.onSuccess && navigate) { navigate(action.onSuccess.navigate); } else if ("set" in action.onSuccess) { for (const [path, value] of Object.entries(action.onSuccess.set)) { setState(path, value); } } else if ("action" in action.onSuccess && executeAction) { await executeAction(action.onSuccess.action); } } } catch (error) { // Handle error if (action.onError) { if ("set" in action.onError) { for (const [path, value] of Object.entries(action.onError.set)) { // Replace $error.message with actual error const resolvedValue = typeof value === "string" && value === "$error.message" ? (error as Error).message : value; setState(path, resolvedValue); } } else if ("action" in action.onError && executeAction) { await executeAction(action.onError.action); } } else { throw error; } } } /** * Helper to create action bindings */ export const actionBinding = { /** Create a simple action binding */ simple: ( actionName: string, params?: Record, ): ActionBinding => ({ action: actionName, params, }), /** Create an action binding with confirmation */ withConfirm: ( actionName: string, confirm: ActionConfirm, params?: Record, ): ActionBinding => ({ action: actionName, params, confirm, }), /** Create an action binding with success handler */ withSuccess: ( actionName: string, onSuccess: ActionOnSuccess, params?: Record, ): ActionBinding => ({ action: actionName, params, onSuccess, }), }; /** * @deprecated Use actionBinding instead */ export const action = actionBinding; ================================================ FILE: packages/core/src/diff.ts ================================================ import type { JsonPatch } from "./types"; /** * Escape a single JSON Pointer token per RFC 6901. * `~` → `~0`, `/` → `~1`. */ function escapeToken(token: string): string { return token.replace(/~/g, "~0").replace(/\//g, "~1"); } function buildPath(basePath: string, key: string): string { return `${basePath}/${escapeToken(key)}`; } function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } /** * Shallow equality for arrays — used to avoid emitting patches when the * children list hasn't actually changed. */ function arraysEqual(a: unknown[], b: unknown[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; } /** * Produce RFC 6902 JSON Patch operations that transform `oldObj` into `newObj`. * * - New keys → `add` * - Changed scalar/array values → `replace` * - Removed keys → `remove` * - Arrays are compared shallowly and replaced atomically (not element-diffed) * - Plain objects recurse */ export function diffToPatches( oldObj: Record, newObj: Record, basePath = "", ): JsonPatch[] { const patches: JsonPatch[] = []; // Keys present in newObj for (const key of Object.keys(newObj)) { const path = buildPath(basePath, key); const oldVal = oldObj[key]; const newVal = newObj[key]; if (!(key in oldObj)) { patches.push({ op: "add", path, value: newVal }); continue; } // Both exist — compare if (isPlainObject(oldVal) && isPlainObject(newVal)) { patches.push(...diffToPatches(oldVal, newVal, path)); } else if (Array.isArray(oldVal) && Array.isArray(newVal)) { if (!arraysEqual(oldVal, newVal)) { patches.push({ op: "replace", path, value: newVal }); } } else if (oldVal !== newVal) { patches.push({ op: "replace", path, value: newVal }); } } // Keys removed from oldObj for (const key of Object.keys(oldObj)) { if (!(key in newObj)) { patches.push({ op: "remove", path: buildPath(basePath, key) }); } } return patches; } ================================================ FILE: packages/core/src/edit-modes.ts ================================================ import type { Spec } from "./types"; /** * Edit mode for modifying an existing spec. * * - `"patch"` — RFC 6902 JSON Patch. One operation per line. * - `"merge"` — RFC 7396 JSON Merge Patch. Partial object deep-merged; `null` deletes. * - `"diff"` — Unified diff (POSIX). Line-level text edits against the serialized spec. */ export type EditMode = "patch" | "merge" | "diff"; export interface EditConfig { /** Which edit modes are enabled. When >1, the AI chooses per edit. */ modes: EditMode[]; } const DEFAULT_MODES: EditMode[] = ["patch"]; function normalizeModes(config?: EditConfig): EditMode[] { if (!config?.modes?.length) return DEFAULT_MODES; return config.modes; } // ── JSON-format instructions ── function jsonPatchInstructions(): string { return [ "PATCH MODE (RFC 6902 JSON Patch):", "Output one JSON object per line. Each line is a patch operation.", '- Add: {"op":"add","path":"/elements/new-key","value":{...}}', '- Replace: {"op":"replace","path":"/elements/existing-key","value":{...}}', '- Remove: {"op":"remove","path":"/elements/old-key"}', "Only output patches for what needs to change.", ].join("\n"); } function jsonMergeInstructions(): string { return [ "MERGE MODE (RFC 7396 JSON Merge Patch):", "Output a single JSON object on one line with __json_edit set to true.", "Include only the keys that changed. Unmentioned keys are preserved.", "Set a key to null to delete it.", "", "Example (update a title and add an element):", '{"__json_edit":true,"elements":{"main":{"props":{"title":"New Title"}},"new-el":{"type":"Card","props":{},"children":[]}}}', "", "Example (delete an element):", '{"__json_edit":true,"elements":{"old-widget":null}}', ].join("\n"); } function jsonDiffInstructions(): string { return [ "DIFF MODE (unified diff):", "Output a unified diff inside a ```diff code fence.", "The diff applies against the JSON-serialized current spec.", "", "Example:", "```diff", "--- a/spec.json", "+++ b/spec.json", "@@ -3,1 +3,1 @@", '- "title": "Login"', '+ "title": "Welcome Back"', "```", ].join("\n"); } // ── YAML-format instructions ── function yamlPatchInstructions(): string { return [ "PATCH MODE (RFC 6902 JSON Patch):", "Output RFC 6902 JSON Patch lines inside a ```yaml-patch code fence.", "Each line is one JSON patch operation.", "", "Example:", "```yaml-patch", '{"op":"replace","path":"/elements/main/props/title","value":"New Title"}', '{"op":"add","path":"/elements/new-el","value":{"type":"Card","props":{},"children":[]}}', "```", ].join("\n"); } function yamlMergeInstructions(): string { return [ "MERGE MODE (RFC 7396 JSON Merge Patch):", "Output only the changed parts in a ```yaml-edit code fence.", "Uses deep merge semantics: only keys you include are updated. Unmentioned elements and props are preserved.", "Set a key to null to delete it.", "", "Example edit (update title, add a new element):", "```yaml-edit", "elements:", " main:", " props:", " title: Updated Title", " new-chart:", " type: Card", " props: {}", " children: []", "```", "", "Example deletion:", "```yaml-edit", "elements:", " old-widget: null", "```", ].join("\n"); } function yamlDiffInstructions(): string { return [ "DIFF MODE (unified diff):", "Output a unified diff inside a ```diff code fence.", "The diff applies against the YAML-serialized current spec.", "", "Example:", "```diff", "--- a/spec.yaml", "+++ b/spec.yaml", "@@ -6,1 +6,1 @@", "- title: Login", "+ title: Welcome Back", "```", ].join("\n"); } // ── Mode selection guidance ── function modeSelectionGuidance(modes: EditMode[]): string { if (modes.length === 1) return ""; const parts = ["Choose the best edit strategy for the requested change:"]; if (modes.includes("patch")) { parts.push("- PATCH: best for precise, targeted single-field updates"); } if (modes.includes("merge")) { parts.push( "- MERGE: best for structural changes (add/remove elements, reparent children, update multiple props at once)", ); } if (modes.includes("diff")) { parts.push( "- DIFF: best for small text-level changes when you can see the exact lines to change", ); } return parts.join("\n"); } /** * Generate the prompt section describing available edit modes. * Only documents the modes that are enabled. */ export function buildEditInstructions( config: EditConfig | undefined, format: "json" | "yaml", ): string { const modes = normalizeModes(config); const sections: string[] = []; sections.push("EDITING EXISTING SPECS:"); sections.push(""); const guidance = modeSelectionGuidance(modes); if (guidance) { sections.push(guidance); sections.push(""); } for (const mode of modes) { if (format === "json") { switch (mode) { case "patch": sections.push(jsonPatchInstructions()); break; case "merge": sections.push(jsonMergeInstructions()); break; case "diff": sections.push(jsonDiffInstructions()); break; } } else { switch (mode) { case "patch": sections.push(yamlPatchInstructions()); break; case "merge": sections.push(yamlMergeInstructions()); break; case "diff": sections.push(yamlDiffInstructions()); break; } } sections.push(""); } return sections.join("\n"); } function addLineNumbers(text: string): string { const lines = text.split("\n"); const width = String(lines.length).length; return lines .map((line, i) => `${String(i + 1).padStart(width)}| ${line}`) .join("\n"); } export function isNonEmptySpec(spec: unknown): spec is Spec { if (!spec || typeof spec !== "object") return false; const s = spec as Record; return ( typeof s.root === "string" && typeof s.elements === "object" && s.elements !== null && Object.keys(s.elements as object).length > 0 ); } export interface BuildEditUserPromptOptions { prompt: string; currentSpec?: Spec | null; config?: EditConfig; format: "json" | "yaml"; maxPromptLength?: number; /** Serialise the spec. Defaults to JSON.stringify for json, must be provided for yaml. */ serializer?: (spec: Spec) => string; } /** * Generate the user prompt for edits, including the current spec * (with line numbers when diff mode is enabled) and mode instructions. */ export function buildEditUserPrompt( options: BuildEditUserPromptOptions, ): string { const { prompt, currentSpec, config, format, maxPromptLength, serializer } = options; let userText = String(prompt || ""); if (maxPromptLength !== undefined && maxPromptLength > 0) { userText = userText.slice(0, maxPromptLength); } if (!isNonEmptySpec(currentSpec)) { return userText; } const modes = normalizeModes(config); const showLineNumbers = modes.includes("diff"); const serialize = serializer ?? ((s: Spec) => JSON.stringify(s, null, 2)); const specText = serialize(currentSpec); const parts: string[] = []; if (showLineNumbers) { parts.push("CURRENT UI STATE (line numbers for reference):"); parts.push("```"); parts.push(addLineNumbers(specText)); parts.push("```"); } else { parts.push( "CURRENT UI STATE (already loaded, DO NOT recreate existing elements):", ); parts.push("```"); parts.push(specText); parts.push("```"); } parts.push(""); parts.push(`USER REQUEST: ${userText}`); parts.push(""); if (modes.length === 1) { const mode = modes[0]!; switch (mode) { case "patch": parts.push( format === "yaml" ? "Output ONLY the patches in a ```yaml-patch fence." : "Output ONLY the JSON Patch lines needed for the change.", ); break; case "merge": parts.push( format === "yaml" ? "Output ONLY the changes in a ```yaml-edit fence. Include only keys that need to change." : "Output ONLY a single JSON merge line with __json_edit set to true. Include only keys that need to change.", ); break; case "diff": parts.push("Output ONLY the unified diff in a ```diff fence."); break; } } else { const modeNames = modes.map((m) => { switch (m) { case "patch": return format === "yaml" ? "```yaml-patch fence" : "JSON Patch lines"; case "merge": return format === "yaml" ? "```yaml-edit fence" : "JSON merge line (__json_edit)"; case "diff": return "```diff fence"; } }); parts.push( `Choose the best edit strategy and output using one of: ${modeNames.join(", ")}`, ); } return parts.join("\n"); } ================================================ FILE: packages/core/src/env.d.ts ================================================ // Minimal process.env typing for dev-only warnings. // Uses a namespaced interface so it merges cleanly with @types/node if present. declare namespace NodeJS { interface ProcessEnv { readonly NODE_ENV?: string; } } declare const process: { readonly env: NodeJS.ProcessEnv }; ================================================ FILE: packages/core/src/index.ts ================================================ // Types export type { DynamicValue, DynamicString, DynamicNumber, DynamicBoolean, UIElement, FlatElement, Spec, VisibilityCondition, StateCondition, ItemCondition, IndexCondition, SingleCondition, AndCondition, OrCondition, StateModel, StateStore, ComponentSchema, ValidationMode, PatchOp, JsonPatch, // SpecStream types SpecStreamLine, SpecStreamCompiler, // Mixed stream types (chat + GenUI) MixedStreamCallbacks, MixedStreamParser, // AI SDK stream transform StreamChunk, SpecDataPart, } from "./types"; export { DynamicValueSchema, DynamicStringSchema, DynamicNumberSchema, DynamicBooleanSchema, resolveDynamicValue, getByPath, setByPath, addByPath, removeByPath, findFormValue, // SpecStream - streaming format for building specs (RFC 6902) parseSpecStreamLine, applySpecStreamPatch, applySpecPatch, nestedToFlat, compileSpecStream, createSpecStreamCompiler, // Mixed stream parser (chat + GenUI) createMixedStreamParser, // AI SDK stream transform createJsonRenderTransform, pipeJsonRender, SPEC_DATA_PART, SPEC_DATA_PART_TYPE, } from "./types"; // State Store export type { StoreAdapterConfig } from "./state-store"; export { createStateStore } from "./state-store"; // Visibility export type { VisibilityContext } from "./visibility"; export { VisibilityConditionSchema, evaluateVisibility, visibility, } from "./visibility"; // Prop Expressions export type { PropExpression, PropResolutionContext, ComputedFunction, } from "./props"; export { resolvePropValue, resolveElementProps, resolveBindings, resolveActionParam, } from "./props"; // Actions export type { ActionBinding, /** @deprecated Use ActionBinding instead */ Action, ActionConfirm, ActionOnSuccess, ActionOnError, ActionHandler, ActionDefinition, ResolvedAction, ActionExecutionContext, } from "./actions"; export { ActionBindingSchema, /** @deprecated Use ActionBindingSchema instead */ ActionSchema, ActionConfirmSchema, ActionOnSuccessSchema, ActionOnErrorSchema, resolveAction, executeAction, interpolateString, actionBinding, /** @deprecated Use actionBinding instead */ action, } from "./actions"; // Validation export type { ValidationCheck, ValidationConfig, ValidationFunction, ValidationFunctionDefinition, ValidationCheckResult, ValidationResult, ValidationContext, } from "./validation"; export { ValidationCheckSchema, ValidationConfigSchema, builtInValidationFunctions, runValidationCheck, runValidation, check, } from "./validation"; // Spec Structural Validation export type { SpecIssueSeverity, SpecIssue, SpecValidationIssues, ValidateSpecOptions, } from "./spec-validator"; export { validateSpec, autoFixSpec, formatSpecIssues } from "./spec-validator"; // Schema — defines the grammar (how specs and catalogs are structured) export type { SchemaBuilder, SchemaType, SchemaDefinition, Schema, PromptTemplate, SchemaOptions, BuiltInAction, } from "./schema"; export { defineSchema } from "./schema"; // Catalog — defines the vocabulary (what components and actions are available) export type { Catalog, JsonSchemaOptions, PromptOptions, PromptContext, SpecValidationResult, InferCatalogInput, InferSpec, InferCatalogComponents, InferCatalogActions, InferComponentProps, InferActionParams, } from "./schema"; export { defineCatalog } from "./schema"; // User Prompt Builder export type { UserPromptOptions } from "./prompt"; export { buildUserPrompt } from "./prompt"; // Object diff & merge (format-agnostic) export { deepMergeSpec } from "./merge"; export { diffToPatches } from "./diff"; // Edit modes export type { EditMode, EditConfig, BuildEditUserPromptOptions, } from "./edit-modes"; export { buildEditInstructions, buildEditUserPrompt, isNonEmptySpec, } from "./edit-modes"; ================================================ FILE: packages/core/src/merge.ts ================================================ function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } /** * Deep-merge `patch` into `base`, returning a new object. * * Semantics (RFC 7396 JSON Merge Patch): * - `null` values in `patch` delete the corresponding key from `base` * - Arrays in `patch` replace (not concat) the corresponding array in `base` * - Plain objects recurse * - All other values replace * * Neither `base` nor `patch` is mutated. */ export function deepMergeSpec( base: Record, patch: Record, ): Record { const result: Record = { ...base }; for (const key of Object.keys(patch)) { const patchVal = patch[key]; // null → delete if (patchVal === null) { delete result[key]; continue; } const baseVal = result[key]; if (isPlainObject(patchVal) && isPlainObject(baseVal)) { result[key] = deepMergeSpec(baseVal, patchVal); } else { result[key] = patchVal; } } return result; } ================================================ FILE: packages/core/src/prompt.ts ================================================ import type { Spec } from "./types"; import type { EditMode } from "./edit-modes"; import { buildEditUserPrompt, isNonEmptySpec } from "./edit-modes"; /** * Options for building a user prompt. */ export interface UserPromptOptions { /** The user's text prompt */ prompt: string; /** Existing spec to refine (triggers patch-only mode) */ currentSpec?: Spec | null; /** Runtime state context to include */ state?: Record | null; /** Maximum length for the user's text prompt (applied before wrapping) */ maxPromptLength?: number; /** Edit modes to offer when refining an existing spec. Default: `["patch"]`. */ editModes?: EditMode[]; /** Wire format. Default: `"json"`. */ format?: "json" | "yaml"; /** Serialise the spec for edits. Defaults to JSON.stringify for json, must be provided for yaml. */ serializer?: (spec: Spec) => string; } /** * Build a user prompt for AI generation. * * Handles common patterns that every consuming app needs: * - Truncating the user's prompt to a max length * - Including the current spec for refinement (edit mode) * - Including runtime state context * * @example * ```ts * // Fresh generation * buildUserPrompt({ prompt: "create a todo app" }) * * // Refinement with existing spec * buildUserPrompt({ prompt: "add a dark mode toggle", currentSpec: spec }) * * // With multiple edit modes * buildUserPrompt({ prompt: "change title", currentSpec: spec, editModes: ["patch", "merge"] }) * ``` */ export function buildUserPrompt(options: UserPromptOptions): string { const { prompt, currentSpec, state, maxPromptLength, editModes, format, serializer, } = options; // Sanitize and optionally truncate the user's text let userText = String(prompt || ""); if (maxPromptLength !== undefined && maxPromptLength > 0) { userText = userText.slice(0, maxPromptLength); } // --- Refinement mode: currentSpec is provided --- if (isNonEmptySpec(currentSpec)) { const editPrompt = buildEditUserPrompt({ prompt: userText, currentSpec, config: { modes: editModes ?? ["patch"] }, format: format ?? "json", serializer, }); // Append state context if provided if (state && Object.keys(state).length > 0) { return `${editPrompt}\n\nAVAILABLE STATE:\n${JSON.stringify(state, null, 2)}`; } return editPrompt; } // --- Fresh generation mode --- const parts: string[] = [userText]; if (state && Object.keys(state).length > 0) { parts.push(`\nAVAILABLE STATE:\n${JSON.stringify(state, null, 2)}`); } if (format === "yaml") { parts.push( `\nOutput the full spec in a \`\`\`yaml-spec fence. Stream progressively — output elements one at a time.`, ); } else { parts.push( `\nRemember: Output /root first, then interleave /elements and /state patches so the UI fills in progressively as it streams. Output each state patch right after the elements that use it, one per array item.`, ); } return parts.join("\n"); } ================================================ FILE: packages/core/src/props.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { resolvePropValue, resolveElementProps, resolveBindings, resolveActionParam, _resetWarnedComputedFns, _resetWarnedTemplatePaths, } from "./props"; import type { PropResolutionContext } from "./props"; // ============================================================================= // resolvePropValue // ============================================================================= describe("resolvePropValue", () => { describe("literals", () => { it("passes through strings", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue("hello", ctx)).toBe("hello"); }); it("passes through numbers", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue(42, ctx)).toBe(42); }); it("passes through booleans", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue(true, ctx)).toBe(true); expect(resolvePropValue(false, ctx)).toBe(false); }); it("passes through null", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue(null, ctx)).toBeNull(); }); it("passes through undefined", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue(undefined, ctx)).toBeUndefined(); }); }); describe("$state expressions", () => { it("resolves a state path", () => { const ctx: PropResolutionContext = { stateModel: { user: { name: "Alice" } }, }; expect(resolvePropValue({ $state: "/user/name" }, ctx)).toBe("Alice"); }); it("returns undefined for missing state path", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue({ $state: "/missing" }, ctx)).toBeUndefined(); }); it("resolves nested state path", () => { const ctx: PropResolutionContext = { stateModel: { a: { b: { c: 42 } } }, }; expect(resolvePropValue({ $state: "/a/b/c" }, ctx)).toBe(42); }); }); describe("$item expressions", () => { it("resolves a field from the repeat item", () => { const ctx: PropResolutionContext = { stateModel: {}, repeatItem: { title: "Hello", id: "1" }, repeatIndex: 0, }; expect(resolvePropValue({ $item: "title" }, ctx)).toBe("Hello"); }); it('resolves "/" to the whole item', () => { const item = { title: "Hello", id: "1" }; const ctx: PropResolutionContext = { stateModel: {}, repeatItem: item, repeatIndex: 0, }; expect(resolvePropValue({ $item: "" }, ctx)).toBe(item); }); it("resolves nested field from item", () => { const ctx: PropResolutionContext = { stateModel: {}, repeatItem: { user: { name: "Bob" } }, repeatIndex: 0, }; expect(resolvePropValue({ $item: "user/name" }, ctx)).toBe("Bob"); }); it("returns undefined when no repeat item in context", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue({ $item: "title" }, ctx)).toBeUndefined(); }); it("returns undefined for missing field on item", () => { const ctx: PropResolutionContext = { stateModel: {}, repeatItem: { title: "Hello" }, repeatIndex: 0, }; expect(resolvePropValue({ $item: "missing" }, ctx)).toBeUndefined(); }); }); describe("$index expressions", () => { it("returns the current repeat index", () => { const ctx: PropResolutionContext = { stateModel: {}, repeatItem: { id: "1" }, repeatIndex: 3, }; expect(resolvePropValue({ $index: true }, ctx)).toBe(3); }); it("returns 0 for first item", () => { const ctx: PropResolutionContext = { stateModel: {}, repeatItem: { id: "1" }, repeatIndex: 0, }; expect(resolvePropValue({ $index: true }, ctx)).toBe(0); }); it("returns undefined when no repeat index in context", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue({ $index: true }, ctx)).toBeUndefined(); }); }); describe("$cond/$then/$else expressions", () => { it("returns $then when condition is true", () => { const ctx: PropResolutionContext = { stateModel: { active: true }, }; expect( resolvePropValue( { $cond: { $state: "/active" }, $then: "blue", $else: "gray" }, ctx, ), ).toBe("blue"); }); it("returns $else when condition is false", () => { const ctx: PropResolutionContext = { stateModel: { active: false }, }; expect( resolvePropValue( { $cond: { $state: "/active" }, $then: "blue", $else: "gray" }, ctx, ), ).toBe("gray"); }); it("handles eq condition", () => { const ctx: PropResolutionContext = { stateModel: { tab: "home" }, }; expect( resolvePropValue( { $cond: { $state: "/tab", eq: "home" }, $then: "#007AFF", $else: "#8E8E93", }, ctx, ), ).toBe("#007AFF"); }); it("handles nested expression in $then/$else", () => { const ctx: PropResolutionContext = { stateModel: { isAdmin: true, admin: { greeting: "Hello Admin" } }, }; expect( resolvePropValue( { $cond: { $state: "/isAdmin" }, $then: { $state: "/admin/greeting" }, $else: "Welcome", }, ctx, ), ).toBe("Hello Admin"); }); it("handles array condition (implicit AND)", () => { const ctx: PropResolutionContext = { stateModel: { isAdmin: true, feature: true }, }; expect( resolvePropValue( { $cond: [{ $state: "/isAdmin" }, { $state: "/feature" }], $then: "yes", $else: "no", }, ctx, ), ).toBe("yes"); }); }); describe("nested objects and arrays", () => { it("resolves expressions inside plain objects", () => { const ctx: PropResolutionContext = { stateModel: { color: "red", size: 12 }, }; const result = resolvePropValue( { fill: { $state: "/color" }, fontSize: { $state: "/size" } }, ctx, ); expect(result).toEqual({ fill: "red", fontSize: 12 }); }); it("resolves expressions inside arrays", () => { const ctx: PropResolutionContext = { stateModel: { a: 1, b: 2 }, }; const result = resolvePropValue( [{ $state: "/a" }, { $state: "/b" }, 3], ctx, ); expect(result).toEqual([1, 2, 3]); }); it("resolves deeply nested expressions", () => { const ctx: PropResolutionContext = { stateModel: { theme: { primary: "#007AFF" } }, }; const result = resolvePropValue( { style: { color: { $state: "/theme/primary" }, margin: 10 } }, ctx, ); expect(result).toEqual({ style: { color: "#007AFF", margin: 10 } }); }); }); }); // ============================================================================= // resolveElementProps // ============================================================================= describe("resolveElementProps", () => { it("resolves all props in an element", () => { const ctx: PropResolutionContext = { stateModel: { user: { name: "Alice", role: "admin" } }, }; const props = { label: { $state: "/user/name" }, badge: { $state: "/user/role" }, static: "always", }; expect(resolveElementProps(props, ctx)).toEqual({ label: "Alice", badge: "admin", static: "always", }); }); it("resolves mixed expressions and literals", () => { const ctx: PropResolutionContext = { stateModel: { active: true }, repeatItem: { title: "Item 1" }, repeatIndex: 2, }; const props = { title: { $item: "title" }, index: { $index: true }, color: { $cond: { $state: "/active" }, $then: "green", $else: "gray", }, width: 100, }; expect(resolveElementProps(props, ctx)).toEqual({ title: "Item 1", index: 2, color: "green", width: 100, }); }); it("returns empty object for empty props", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolveElementProps({}, ctx)).toEqual({}); }); }); // ============================================================================= // $bindState / $bindItem expressions // ============================================================================= describe("$bindState expressions", () => { describe("resolvePropValue with $bindState", () => { it("resolves to the state value at the path", () => { const ctx: PropResolutionContext = { stateModel: { form: { email: "alice@example.com" } }, }; expect(resolvePropValue({ $bindState: "/form/email" }, ctx)).toBe( "alice@example.com", ); }); it("returns undefined for missing path", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue({ $bindState: "/missing" }, ctx)).toBeUndefined(); }); }); describe("resolvePropValue with $bindItem", () => { it("resolves item field using repeatBasePath", () => { const ctx: PropResolutionContext = { stateModel: { todos: [{ completed: true }, { completed: false }] }, repeatItem: { completed: true }, repeatIndex: 0, repeatBasePath: "/todos/0", }; expect(resolvePropValue({ $bindItem: "completed" }, ctx)).toBe(true); }); it('handles "/" as the full item path', () => { const ctx: PropResolutionContext = { stateModel: { items: ["hello", "world"] }, repeatItem: "hello", repeatIndex: 0, repeatBasePath: "/items/0", }; expect(resolvePropValue({ $bindItem: "" }, ctx)).toBe("hello"); }); it("returns undefined when no repeatBasePath", () => { const ctx: PropResolutionContext = { stateModel: {}, repeatItem: { completed: true }, repeatIndex: 0, }; // Without repeatBasePath, the raw item path won't resolve in stateModel expect(resolvePropValue({ $bindItem: "completed" }, ctx)).toBeUndefined(); }); }); describe("resolveBindings", () => { it("extracts $bindState paths from props", () => { const ctx: PropResolutionContext = { stateModel: {} }; const props = { value: { $bindState: "/form/email" }, label: "Email", placeholder: "Enter email", }; expect(resolveBindings(props, ctx)).toEqual({ value: "/form/email", }); }); it("returns undefined when no bind expressions", () => { const ctx: PropResolutionContext = { stateModel: {} }; const props = { label: "Hello", count: 42, }; expect(resolveBindings(props, ctx)).toBeUndefined(); }); it("handles multiple $bindState props", () => { const ctx: PropResolutionContext = { stateModel: {} }; const props = { value: { $bindState: "/form/name" }, checked: { $bindState: "/form/agree" }, label: "Name", }; expect(resolveBindings(props, ctx)).toEqual({ value: "/form/name", checked: "/form/agree", }); }); it("resolves $bindItem paths using repeatBasePath", () => { const ctx: PropResolutionContext = { stateModel: {}, repeatItem: { completed: false }, repeatIndex: 1, repeatBasePath: "/todos/1", }; const props = { checked: { $bindItem: "completed" }, label: { $item: "title" }, }; expect(resolveBindings(props, ctx)).toEqual({ checked: "/todos/1/completed", }); }); it("ignores non-bind dynamic expressions", () => { const ctx: PropResolutionContext = { stateModel: {} }; const props = { title: { $state: "/title" }, index: { $index: true }, name: { $item: "name" }, value: { $bindState: "/path" }, }; expect(resolveBindings(props, ctx)).toEqual({ value: "/path", }); }); it("handles mixed $bindState and $bindItem props", () => { const ctx: PropResolutionContext = { stateModel: {}, repeatItem: { done: false }, repeatIndex: 0, repeatBasePath: "/todos/0", }; const props = { value: { $bindState: "/form/search" }, checked: { $bindItem: "done" }, label: "Task", }; expect(resolveBindings(props, ctx)).toEqual({ value: "/form/search", checked: "/todos/0/done", }); }); }); }); // ============================================================================= // resolveActionParam // ============================================================================= describe("resolveActionParam", () => { it("resolves $item to an absolute state path via repeatBasePath", () => { const ctx: PropResolutionContext = { stateModel: { todos: [{ title: "Buy milk" }] }, repeatItem: { title: "Buy milk" }, repeatIndex: 0, repeatBasePath: "/todos/0", }; expect(resolveActionParam({ $item: "title" }, ctx)).toBe("/todos/0/title"); }); it("resolves $item with empty string to the repeatBasePath itself", () => { const ctx: PropResolutionContext = { stateModel: { items: ["a", "b"] }, repeatItem: "a", repeatIndex: 0, repeatBasePath: "/items/0", }; expect(resolveActionParam({ $item: "" }, ctx)).toBe("/items/0"); }); it("returns undefined for $item when no repeatBasePath", () => { const ctx: PropResolutionContext = { stateModel: {}, repeatItem: { title: "Hello" }, repeatIndex: 0, }; expect(resolveActionParam({ $item: "title" }, ctx)).toBeUndefined(); }); it("resolves $index to the current repeat index", () => { const ctx: PropResolutionContext = { stateModel: {}, repeatItem: { id: "1" }, repeatIndex: 5, }; expect(resolveActionParam({ $index: true }, ctx)).toBe(5); }); it("returns undefined for $index when no repeat context", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolveActionParam({ $index: true }, ctx)).toBeUndefined(); }); it("delegates $state expressions to resolvePropValue", () => { const ctx: PropResolutionContext = { stateModel: { form: { id: "abc-123" } }, }; expect(resolveActionParam({ $state: "/form/id" }, ctx)).toBe("abc-123"); }); it("passes through literal strings", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolveActionParam("submit", ctx)).toBe("submit"); }); it("passes through literal numbers", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolveActionParam(42, ctx)).toBe(42); }); it("passes through null", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolveActionParam(null, ctx)).toBeNull(); }); }); // ============================================================================= // $computed expressions // ============================================================================= describe("$computed expressions", () => { it("calls a registered function with resolved args", () => { const ctx: PropResolutionContext = { stateModel: { form: { firstName: "Jane", lastName: "Doe" } }, functions: { fullName: (args) => `${args.first} ${args.last}`, }, }; expect( resolvePropValue( { $computed: "fullName", args: { first: { $state: "/form/firstName" }, last: { $state: "/form/lastName" }, }, }, ctx, ), ).toBe("Jane Doe"); }); it("calls function with no args", () => { const ctx: PropResolutionContext = { stateModel: {}, functions: { timestamp: () => 1234567890, }, }; expect(resolvePropValue({ $computed: "timestamp" }, ctx)).toBe(1234567890); }); it("returns undefined for unknown function", () => { const ctx: PropResolutionContext = { stateModel: {}, functions: {}, }; expect(resolvePropValue({ $computed: "unknown" }, ctx)).toBeUndefined(); }); it("returns undefined when no functions in context", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue({ $computed: "any" }, ctx)).toBeUndefined(); }); it("deduplicates warnings for the same unknown function", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const ctx: PropResolutionContext = { stateModel: {}, functions: {} }; resolvePropValue({ $computed: "dedupTest" }, ctx); resolvePropValue({ $computed: "dedupTest" }, ctx); const calls = warnSpy.mock.calls.filter((c) => String(c[0]).includes("dedupTest"), ); expect(calls).toHaveLength(1); warnSpy.mockRestore(); }); it("resolves nested expressions in args", () => { const ctx: PropResolutionContext = { stateModel: { active: true, values: { a: 10, b: 20 } }, functions: { conditionalSum: (args) => { if (args.enabled) return (args.x as number) + (args.y as number); return 0; }, }, }; expect( resolvePropValue( { $computed: "conditionalSum", args: { enabled: { $state: "/active" }, x: { $state: "/values/a" }, y: { $state: "/values/b" }, }, }, ctx, ), ).toBe(30); }); }); // ============================================================================= // $template expressions // ============================================================================= describe("$template expressions", () => { it("interpolates state values into a string", () => { const ctx: PropResolutionContext = { stateModel: { user: { name: "Alice" }, count: 3 }, }; expect( resolvePropValue( { $template: "Hello, ${/user/name}! You have ${/count} messages." }, ctx, ), ).toBe("Hello, Alice! You have 3 messages."); }); it("replaces missing paths with empty string", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue({ $template: "Hi ${/name}!" }, ctx)).toBe("Hi !"); }); it("handles template with no interpolations", () => { const ctx: PropResolutionContext = { stateModel: {} }; expect(resolvePropValue({ $template: "No variables here" }, ctx)).toBe( "No variables here", ); }); it("handles multiple references to the same path", () => { const ctx: PropResolutionContext = { stateModel: { x: "A" }, }; expect(resolvePropValue({ $template: "${/x} and ${/x}" }, ctx)).toBe( "A and A", ); }); it("converts non-string values to strings", () => { const ctx: PropResolutionContext = { stateModel: { num: 42, bool: true }, }; expect(resolvePropValue({ $template: "${/num} is ${/bool}" }, ctx)).toBe( "42 is true", ); }); it("warns when path does not start with /", () => { _resetWarnedTemplatePaths(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const ctx: PropResolutionContext = { stateModel: { name: "Bob" } }; const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx); expect(result).toBe("Hi Bob!"); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('$template path "name"'), ); warnSpy.mockRestore(); _resetWarnedTemplatePaths(); }); it("deduplicates warnings for the same $template path", () => { _resetWarnedTemplatePaths(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const ctx: PropResolutionContext = { stateModel: { name: "Bob" } }; resolvePropValue({ $template: "Hi ${name}!" }, ctx); resolvePropValue({ $template: "Hi ${name}!" }, ctx); const calls = warnSpy.mock.calls.filter((c) => String(c[0]).includes('$template path "name"'), ); expect(calls).toHaveLength(1); warnSpy.mockRestore(); _resetWarnedTemplatePaths(); }); }); ================================================ FILE: packages/core/src/props.ts ================================================ import type { VisibilityCondition, StateModel } from "./types"; import { getByPath } from "./types"; import { evaluateVisibility, type VisibilityContext } from "./visibility"; // ============================================================================= // Prop Expression Types // ============================================================================= /** * A prop expression that resolves to a value based on state. * * - `{ $state: string }` reads a value from the global state model * - `{ $item: string }` reads a field from the current repeat item * (relative path into the item object; use `""` for the whole item) * - `{ $index: true }` returns the current repeat array index. Uses `true` * as a sentinel flag because the index is a scalar with no sub-path to * navigate — unlike `$item` which needs a path into the item object. * - `{ $bindState: string }` two-way binding to a global state path — * resolves to the value at the path (like `$state`) AND exposes the * resolved path so the component can write back. * - `{ $bindItem: string }` two-way binding to a field on the current * repeat item — resolves via `repeatBasePath + path` and exposes the * absolute state path for write-back. * - `{ $cond, $then, $else }` conditionally picks a value * - `{ $computed: string, args?: Record }` calls a * registered function with resolved args and returns the result * - `{ $template: string }` interpolates `${/path}` references in the * string with values from the state model * - Any other value is a literal (passthrough) */ export type PropExpression = | T | { $state: string } | { $item: string } | { $index: true } | { $bindState: string } | { $bindItem: string } | { $cond: VisibilityCondition; $then: PropExpression; $else: PropExpression; } | { $computed: string; args?: Record } | { $template: string }; /** * Function signature for `$computed` expressions. * Receives a record of resolved argument values and returns a computed result. */ export type ComputedFunction = (args: Record) => unknown; /** * Context for resolving prop expressions. * Extends {@link VisibilityContext} with an optional `repeatBasePath` used * to resolve `$bindItem` paths to absolute state paths. */ export interface PropResolutionContext extends VisibilityContext { /** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */ repeatBasePath?: string; /** Named functions available for `$computed` expressions. */ functions?: Record; } // ============================================================================= // Type Guards // ============================================================================= function isStateExpression(value: unknown): value is { $state: string } { return ( typeof value === "object" && value !== null && "$state" in value && typeof (value as Record).$state === "string" ); } function isItemExpression(value: unknown): value is { $item: string } { return ( typeof value === "object" && value !== null && "$item" in value && typeof (value as Record).$item === "string" ); } function isIndexExpression(value: unknown): value is { $index: true } { return ( typeof value === "object" && value !== null && "$index" in value && (value as Record).$index === true ); } function isBindStateExpression( value: unknown, ): value is { $bindState: string } { return ( typeof value === "object" && value !== null && "$bindState" in value && typeof (value as Record).$bindState === "string" ); } function isBindItemExpression(value: unknown): value is { $bindItem: string } { return ( typeof value === "object" && value !== null && "$bindItem" in value && typeof (value as Record).$bindItem === "string" ); } function isCondExpression( value: unknown, ): value is { $cond: VisibilityCondition; $then: unknown; $else: unknown } { return ( typeof value === "object" && value !== null && "$cond" in value && "$then" in value && "$else" in value ); } function isComputedExpression( value: unknown, ): value is { $computed: string; args?: Record } { return ( typeof value === "object" && value !== null && "$computed" in value && typeof (value as Record).$computed === "string" ); } function isTemplateExpression(value: unknown): value is { $template: string } { return ( typeof value === "object" && value !== null && "$template" in value && typeof (value as Record).$template === "string" ); } // Module-level set to avoid spamming console.warn on every render for the same // unknown $computed function name. Once the set reaches WARNED_COMPUTED_MAX, // new names are no longer deduplicated (warnings still fire) but the set stops // growing, preventing unbounded memory use in long-lived processes (e.g. SSR). const WARNED_COMPUTED_MAX = 100; const warnedComputedFns = new Set(); /** @internal Test-only: clear the deduplication set for $computed warnings. */ export function _resetWarnedComputedFns(): void { warnedComputedFns.clear(); } // Same deduplication pattern for $template paths that don't start with "/". const WARNED_TEMPLATE_MAX = 100; const warnedTemplatePaths = new Set(); /** @internal Test-only: clear the deduplication set for $template warnings. */ export function _resetWarnedTemplatePaths(): void { warnedTemplatePaths.clear(); } // ============================================================================= // Prop Expression Resolution // ============================================================================= // ============================================================================= // $bindItem path resolution helper // ============================================================================= /** * Resolve a `$bindItem` path into an absolute state path using the repeat * scope's base path. * * `""` resolves to `repeatBasePath` (the whole item). * `"field"` resolves to `repeatBasePath + "/field"`. * * Returns `undefined` when no `repeatBasePath` is available (i.e. `$bindItem` * is used outside a repeat scope). */ function resolveBindItemPath( itemPath: string, ctx: PropResolutionContext, ): string | undefined { if (ctx.repeatBasePath == null) { console.warn(`$bindItem used outside repeat scope: "${itemPath}"`); return undefined; } if (itemPath === "") return ctx.repeatBasePath; return ctx.repeatBasePath + "/" + itemPath; } // ============================================================================= // Prop Expression Resolution // ============================================================================= /** * Resolve a single prop value that may contain expressions. * Handles $state, $item, $index, $bindState, $bindItem, and $cond/$then/$else in a single pass. */ export function resolvePropValue( value: unknown, ctx: PropResolutionContext, ): unknown { if (value === null || value === undefined) { return value; } // $state: read from global state model if (isStateExpression(value)) { return getByPath(ctx.stateModel, value.$state); } // $item: read from current repeat item if (isItemExpression(value)) { if (ctx.repeatItem === undefined) return undefined; // "" means the whole item, "field" means a field on the item return value.$item === "" ? ctx.repeatItem : getByPath(ctx.repeatItem, value.$item); } // $index: return current repeat array index if (isIndexExpression(value)) { return ctx.repeatIndex; } // $bindState: two-way binding to global state path if (isBindStateExpression(value)) { return getByPath(ctx.stateModel, value.$bindState); } // $bindItem: two-way binding to repeat item field if (isBindItemExpression(value)) { const resolvedPath = resolveBindItemPath(value.$bindItem, ctx); if (resolvedPath === undefined) return undefined; return getByPath(ctx.stateModel, resolvedPath); } // $cond/$then/$else: evaluate condition and pick branch if (isCondExpression(value)) { const result = evaluateVisibility(value.$cond, ctx); return resolvePropValue(result ? value.$then : value.$else, ctx); } // $computed: call a registered function with resolved args if (isComputedExpression(value)) { const fn = ctx.functions?.[value.$computed]; if (!fn) { if (!warnedComputedFns.has(value.$computed)) { if (warnedComputedFns.size < WARNED_COMPUTED_MAX) { warnedComputedFns.add(value.$computed); } console.warn(`Unknown $computed function: "${value.$computed}"`); } return undefined; } const resolvedArgs: Record = {}; if (value.args) { for (const [key, arg] of Object.entries(value.args)) { resolvedArgs[key] = resolvePropValue(arg, ctx); } } return fn(resolvedArgs); } // $template: interpolate ${/path} references with state values if (isTemplateExpression(value)) { return value.$template.replace( /\$\{([^}]+)\}/g, (_match, rawPath: string) => { let path = rawPath; if (!path.startsWith("/")) { if (!warnedTemplatePaths.has(path)) { if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) { warnedTemplatePaths.add(path); } console.warn( `$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`, ); } path = "/" + path; } const resolved = getByPath(ctx.stateModel, path); return resolved != null ? String(resolved) : ""; }, ); } // Arrays: resolve each element if (Array.isArray(value)) { return value.map((item) => resolvePropValue(item, ctx)); } // Plain objects (not expressions): resolve each value recursively if (typeof value === "object") { const resolved: Record = {}; for (const [key, val] of Object.entries(value as Record)) { resolved[key] = resolvePropValue(val, ctx); } return resolved; } // Primitive literal: passthrough return value; } /** * Resolve all prop values in an element's props object. * Returns a new props object with all expressions resolved. */ export function resolveElementProps( props: Record, ctx: PropResolutionContext, ): Record { const resolved: Record = {}; for (const [key, value] of Object.entries(props)) { resolved[key] = resolvePropValue(value, ctx); } return resolved; } /** * Scan an element's raw props for `$bindState` / `$bindItem` expressions * and return a map of prop name → resolved absolute state path. * * This is called **before** `resolveElementProps` so the component can * receive both the resolved value (in `props`) and the write-back path * (in `bindings`). * * @example * ```ts * const rawProps = { value: { $bindState: "/form/email" }, label: "Email" }; * const bindings = resolveBindings(rawProps, ctx); * // bindings = { value: "/form/email" } * ``` */ export function resolveBindings( props: Record, ctx: PropResolutionContext, ): Record | undefined { let bindings: Record | undefined; for (const [key, value] of Object.entries(props)) { if (isBindStateExpression(value)) { if (!bindings) bindings = {}; bindings[key] = value.$bindState; } else if (isBindItemExpression(value)) { const resolved = resolveBindItemPath(value.$bindItem, ctx); if (resolved !== undefined) { if (!bindings) bindings = {}; bindings[key] = resolved; } } } return bindings; } /** * Resolve a single action parameter value. * * Like {@link resolvePropValue} but with special handling for path-valued * params: `{ $item: "field" }` resolves to an **absolute state path** * (e.g. `/todos/0/field`) instead of the field's value, so the path can * be passed to `setState` / `pushState` / `removeState`. * * - `{ $item: "field" }` → absolute state path via `repeatBasePath` * - `{ $index: true }` → current repeat index (number) * - Everything else delegates to `resolvePropValue` ($state, $cond, literals). */ export function resolveActionParam( value: unknown, ctx: PropResolutionContext, ): unknown { if (isItemExpression(value)) { return resolveBindItemPath(value.$item, ctx); } if (isIndexExpression(value)) { return ctx.repeatIndex; } return resolvePropValue(value, ctx); } ================================================ FILE: packages/core/src/schema.test.ts ================================================ import { describe, it, expect } from "vitest"; import { z } from "zod"; import { defineSchema, defineCatalog } from "./schema"; // ============================================================================= // Shared test schema (mirrors the React schema shape) // ============================================================================= const testSchema = defineSchema((s) => ({ spec: s.object({ root: s.string(), elements: s.record( s.object({ type: s.ref("catalog.components"), props: s.propsOf("catalog.components"), children: s.array(s.string()), visible: s.any(), }), ), }), catalog: s.object({ components: s.map({ props: s.zod(), slots: s.array(s.string()), description: s.string(), example: s.any(), }), actions: s.map({ description: s.string(), }), }), })); // ============================================================================= // defineSchema // ============================================================================= describe("defineSchema", () => { it("creates a schema with spec and catalog definition", () => { const schema = defineSchema((s) => ({ spec: s.object({ root: s.string() }), catalog: s.object({ components: s.map({ props: s.zod() }) }), })); expect(schema.definition).toBeDefined(); expect(schema.definition.spec.kind).toBe("object"); expect(schema.definition.catalog.kind).toBe("object"); }); it("accepts promptTemplate option", () => { const template = () => "custom prompt"; const schema = defineSchema( (s) => ({ spec: s.object({ root: s.string() }), catalog: s.object({ components: s.map({ props: s.zod() }) }), }), { promptTemplate: template }, ); expect(schema.promptTemplate).toBe(template); }); it("accepts defaultRules option", () => { const schema = defineSchema( (s) => ({ spec: s.object({ root: s.string() }), catalog: s.object({ components: s.map({ props: s.zod() }) }), }), { defaultRules: ["Rule A", "Rule B"] }, ); expect(schema.defaultRules).toEqual(["Rule A", "Rule B"]); }); it("exposes createCatalog method", () => { const schema = defineSchema((s) => ({ spec: s.object({ root: s.string() }), catalog: s.object({ components: s.map({ props: s.zod() }) }), })); expect(typeof schema.createCatalog).toBe("function"); }); }); // ============================================================================= // defineCatalog / createCatalog // ============================================================================= describe("defineCatalog", () => { it("creates catalog with componentNames and actionNames", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "Display text", slots: [], }, Button: { props: z.object({ label: z.string() }), description: "A clickable button", slots: [], }, }, actions: { navigate: { description: "Navigate to URL" }, submit: { description: "Submit form" }, }, }); expect(catalog.componentNames).toEqual(["Text", "Button"]); expect(catalog.actionNames).toEqual(["navigate", "submit"]); }); it("handles empty components and actions", () => { const catalog = defineCatalog(testSchema, { components: {}, actions: {}, }); expect(catalog.componentNames).toEqual([]); expect(catalog.actionNames).toEqual([]); }); it("is equivalent to schema.createCatalog", () => { const catalogData = { components: { Card: { props: z.object({ title: z.string() }), description: "A card", slots: ["default"], }, }, actions: {}, }; const a = defineCatalog(testSchema, catalogData); const b = testSchema.createCatalog(catalogData); expect(a.componentNames).toEqual(b.componentNames); expect(a.actionNames).toEqual(b.actionNames); expect(a.data).toBe(b.data); }); it("exposes the schema on the catalog", () => { const catalog = defineCatalog(testSchema, { components: {}, actions: {}, }); expect(catalog.schema).toBe(testSchema); }); it("exposes catalog data", () => { const data = { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, }, actions: {}, }; const catalog = defineCatalog(testSchema, data); expect(catalog.data).toBe(data); }); }); // ============================================================================= // catalog.prompt() // ============================================================================= describe("catalog.prompt", () => { it("includes AVAILABLE COMPONENTS section", () => { const catalog = defineCatalog(testSchema, { components: { Card: { props: z.object({ title: z.string(), names: z.array(z.string()), users: z.array(z.object({ name: z.string(), age: z.number() })), }), description: "A card container", slots: ["default"], }, }, actions: {}, }); const prompt = catalog.prompt(); expect(prompt).toContain("AVAILABLE COMPONENTS"); expect(prompt).toContain("Card"); expect(prompt).toContain("A card container"); expect(prompt).toContain("title: string"); expect(prompt).toContain("names: Array"); expect(prompt).toContain("users: Array<{ name: string, age: number }>"); }); it("includes AVAILABLE ACTIONS when present", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, }, actions: { navigate: { description: "Navigate to URL" }, }, }); const prompt = catalog.prompt(); expect(prompt).toContain("AVAILABLE ACTIONS"); expect(prompt).toContain("navigate"); expect(prompt).toContain("Navigate to URL"); }); it("omits AVAILABLE ACTIONS when there are none", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, }, actions: {}, }); const prompt = catalog.prompt(); expect(prompt).not.toContain("AVAILABLE ACTIONS"); }); it("uses custom system message when provided", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({}), description: "", slots: [], }, }, actions: {}, }); const prompt = catalog.prompt({ system: "You are a dashboard builder." }); expect(prompt).toContain("You are a dashboard builder."); expect(prompt).not.toContain("You are a UI generator that outputs JSON."); }); it("appends customRules to prompt", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({}), description: "", slots: [], }, }, actions: {}, }); const prompt = catalog.prompt({ customRules: ["Always use Card as root", "Keep UIs simple"], }); expect(prompt).toContain("Always use Card as root"); expect(prompt).toContain("Keep UIs simple"); }); it("generates inline mode prompt when mode is inline", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({}), description: "", slots: [], }, }, actions: {}, }); const prompt = catalog.prompt({ mode: "inline" }); expect(prompt).toContain("```spec"); expect(prompt).toContain("conversationally"); expect(prompt).toContain("text + JSONL"); }); it("generates standalone mode prompt by default", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({}), description: "", slots: [], }, }, actions: {}, }); const prompt = catalog.prompt(); expect(prompt).toContain("Output ONLY JSONL patches"); expect(prompt).not.toContain("conversationally"); }); it("accepts deprecated 'chat' as alias for 'inline'", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({}), description: "", slots: [], }, }, actions: {}, }); const inlinePrompt = catalog.prompt({ mode: "inline" }); const chatPrompt = catalog.prompt({ mode: "chat" }); expect(chatPrompt).toEqual(inlinePrompt); }); it("accepts deprecated 'generate' as alias for 'standalone'", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({}), description: "", slots: [], }, }, actions: {}, }); const standalonePrompt = catalog.prompt({ mode: "standalone" }); const generatePrompt = catalog.prompt({ mode: "generate" }); expect(generatePrompt).toEqual(standalonePrompt); }); it("uses actual catalog component names in examples", () => { const catalog = defineCatalog(testSchema, { components: { MyBox: { props: z.object({ padding: z.number() }), description: "A box", slots: ["default"], }, MyLabel: { props: z.object({ text: z.string() }), description: "A label", slots: [], }, }, actions: {}, }); const prompt = catalog.prompt(); expect(prompt).toContain('"type":"MyBox"'); expect(prompt).toContain('"type":"MyLabel"'); }); it("does not include hardcoded component names not in catalog", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, }, actions: {}, }); const prompt = catalog.prompt(); const hardcoded = ["Stack", "Grid", "Heading", "Column", "Pressable"]; for (const comp of hardcoded) { expect(prompt).not.toContain(`"type":"${comp}"`); } }); it("generates example props from Zod schemas", () => { const catalog = defineCatalog(testSchema, { components: { Widget: { props: z.object({ title: z.string(), count: z.number(), active: z.boolean(), variant: z.enum(["primary", "secondary"]), }), description: "", slots: [], }, }, actions: {}, }); const prompt = catalog.prompt(); expect(prompt).toContain('"title":"example"'); expect(prompt).toContain('"count":0'); expect(prompt).toContain('"active":true'); expect(prompt).toContain('"variant":"primary"'); }); it("uses explicit example over Zod-generated values", () => { const catalog = defineCatalog(testSchema, { components: { Heading: { props: z.object({ text: z.string(), level: z.enum(["h1", "h2", "h3"]), }), description: "A heading", slots: [], example: { text: "Welcome", level: "h1" }, }, }, actions: {}, }); const prompt = catalog.prompt(); expect(prompt).toContain('"text":"Welcome"'); expect(prompt).toContain('"level":"h1"'); }); it("uses custom promptTemplate when schema has one", () => { const customSchema = defineSchema( (s) => ({ spec: s.object({ root: s.string() }), catalog: s.object({ components: s.map({ props: s.zod(), description: s.string() }), }), }), { promptTemplate: (ctx) => `Custom prompt with ${ctx.componentNames.length} components: ${ctx.componentNames.join(", ")}`, }, ); const catalog = customSchema.createCatalog({ components: { Alpha: { props: z.object({}), description: "A" }, Beta: { props: z.object({}), description: "B" }, }, }); const prompt = catalog.prompt(); expect(prompt).toBe("Custom prompt with 2 components: Alpha, Beta"); }); it("includes defaultRules from schema in the RULES section", () => { const schemaWithRules = defineSchema( (s) => ({ spec: s.object({ root: s.string(), elements: s.record( s.object({ type: s.ref("catalog.components"), props: s.any(), children: s.array(s.string()), }), ), }), catalog: s.object({ components: s.map({ props: s.zod(), description: s.string() }), }), }), { defaultRules: ["Schema default rule one", "Schema default rule two"] }, ); const catalog = schemaWithRules.createCatalog({ components: { Text: { props: z.object({}), description: "" }, }, }); const prompt = catalog.prompt(); expect(prompt).toContain("Schema default rule one"); expect(prompt).toContain("Schema default rule two"); }); it("contains sections for state, repeat, actions, visibility, and dynamic props", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, }, actions: {}, }); const prompt = catalog.prompt(); expect(prompt).toContain("INITIAL STATE:"); expect(prompt).toContain("DYNAMIC LISTS (repeat field):"); expect(prompt).toContain("EVENTS (the `on` field):"); expect(prompt).toContain("VISIBILITY CONDITIONS:"); expect(prompt).toContain("DYNAMIC PROPS:"); expect(prompt).toContain("RULES:"); }); }); // ============================================================================= // catalog.validate() // ============================================================================= describe("catalog.validate", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, Card: { props: z.object({ title: z.string() }), description: "", slots: ["default"], }, }, actions: {}, }); it("validates a valid spec", () => { const spec = { root: "card-1", elements: { "card-1": { type: "Card", props: { title: "Hello" }, children: ["text-1"], }, "text-1": { type: "Text", props: { content: "World" }, children: [], }, }, }; const result = catalog.validate(spec); expect(result.success).toBe(true); expect(result.data).toEqual(spec); }); it("rejects spec with wrong root type", () => { const result = catalog.validate({ root: 123, elements: {} }); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); it("rejects spec with missing root", () => { const result = catalog.validate({ elements: {} }); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); it("rejects spec with invalid component type", () => { const result = catalog.validate({ root: "x", elements: { x: { type: "NonExistent", props: {}, children: [] }, }, }); expect(result.success).toBe(false); }); it("returns data on success", () => { const spec = { root: "t", elements: { t: { type: "Text", props: { content: "hi" }, children: [] }, }, }; const result = catalog.validate(spec); expect(result.success).toBe(true); expect(result.data).toBeDefined(); expect(result.data!.root).toBe("t"); }); }); // ============================================================================= // catalog.jsonSchema() // ============================================================================= describe("catalog.jsonSchema", () => { it("returns a JSON Schema object", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, }, actions: {}, }); const jsonSchema = catalog.jsonSchema(); expect(jsonSchema).toBeDefined(); expect(typeof jsonSchema).toBe("object"); }); it("returns a non-empty object for a catalog with components", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, }, actions: {}, }); const jsonSchema = catalog.jsonSchema(); expect(jsonSchema).toBeDefined(); expect(jsonSchema).not.toBeNull(); expect(typeof jsonSchema).toBe("object"); }); describe("strict mode (LLM structured output compatible)", () => { function hasNoPropertyNames(obj: unknown): boolean { if (typeof obj !== "object" || obj === null) return true; if ("propertyNames" in obj) return false; return Object.values(obj).every(hasNoPropertyNames); } function allObjectsHaveAdditionalPropertiesFalse(obj: unknown): boolean { if (typeof obj !== "object" || obj === null) return true; const record = obj as Record; if (record.type === "object") { if (record.additionalProperties !== false) return false; } return Object.values(record).every( allObjectsHaveAdditionalPropertiesFalse, ); } function allObjectPropertiesRequired(obj: unknown): boolean { if (typeof obj !== "object" || obj === null) return true; const record = obj as Record; if ( record.type === "object" && record.properties && typeof record.properties === "object" ) { const propKeys = Object.keys(record.properties); const required = (record.required as string[]) ?? []; if (!propKeys.every((k) => required.includes(k))) return false; } return Object.values(record).every(allObjectPropertiesRequired); } it("sets additionalProperties: false on all nested objects", () => { const catalog = defineCatalog(testSchema, { components: { Card: { props: z.object({ title: z.string(), subtitle: z.string().optional(), }), description: "", slots: [], }, }, actions: {}, }); const schema = catalog.jsonSchema({ strict: true }); expect(allObjectsHaveAdditionalPropertiesFalse(schema)).toBe(true); }); it("does not emit propertyNames", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, }, actions: {}, }); const schema = catalog.jsonSchema({ strict: true }); expect(hasNoPropertyNames(schema)).toBe(true); }); it("lists all properties in required (optional uses nullable)", () => { const catalog = defineCatalog(testSchema, { components: { Card: { props: z.object({ title: z.string(), subtitle: z.string().optional(), }), description: "", slots: [], }, }, actions: {}, }); const schema = catalog.jsonSchema({ strict: true }); expect(allObjectPropertiesRequired(schema)).toBe(true); }); it("converts record types without additionalProperties schema value", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, }, actions: {}, }); const schema = catalog.jsonSchema({ strict: true, }) as Record; // Walk the schema and ensure no additionalProperties is set to a non-false value function noAdditionalPropertiesSchema(obj: unknown): boolean { if (typeof obj !== "object" || obj === null) return true; const rec = obj as Record; if ( "additionalProperties" in rec && rec.additionalProperties !== false ) { return false; } return Object.values(rec).every(noAdditionalPropertiesSchema); } expect(noAdditionalPropertiesSchema(schema)).toBe(true); }); it("wraps optional properties with anyOf nullable", () => { // Use a schema where propsOf resolves to a single component's props // (no record wrapper around the props) so the optional anyOf handling // is directly visible in the JSON Schema output. const flatSchema = defineSchema((s) => ({ spec: s.object({ component: s.object({ type: s.ref("catalog.components"), props: s.propsOf("catalog.components"), }), }), catalog: s.object({ components: s.map({ props: s.zod(), description: s.string(), }), }), })); const catalog = defineCatalog(flatSchema, { components: { Card: { props: z.object({ heading: z.string(), caption: z.string().optional(), }), description: "", }, }, }); const schema = catalog.jsonSchema({ strict: true }) as { properties: { component: { properties: { props: Record }; }; }; }; const propsSchema = schema.properties.component.properties.props; // caption is optional – in strict mode it must be in `required` // and wrapped in anyOf with null const captionSchema = ( propsSchema as { properties: { caption: Record }; } ).properties.caption; expect(captionSchema).toEqual({ anyOf: [{ type: "string" }, { type: "null" }], }); const propsRequired = (propsSchema as { required: string[] }).required; expect(propsRequired).toContain("heading"); expect(propsRequired).toContain("caption"); }); it("does not affect default (non-strict) output", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, }, actions: {}, }); const defaultSchema = catalog.jsonSchema(); const defaultSchema2 = catalog.jsonSchema({ strict: false }); expect(defaultSchema).toEqual(defaultSchema2); }); }); }); // ============================================================================= // catalog.zodSchema() // ============================================================================= describe("catalog.zodSchema", () => { it("returns a Zod schema that validates valid specs", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({ content: z.string() }), description: "", slots: [], }, }, actions: {}, }); const zodSchema = catalog.zodSchema(); const result = zodSchema.safeParse({ root: "t", elements: { t: { type: "Text", props: { content: "hi" }, children: [] }, }, }); expect(result.success).toBe(true); }); it("returns a Zod schema that rejects invalid specs", () => { const catalog = defineCatalog(testSchema, { components: { Text: { props: z.object({}), description: "", slots: [], }, }, actions: {}, }); const zodSchema = catalog.zodSchema(); const result = zodSchema.safeParse({ root: 42 }); expect(result.success).toBe(false); }); }); ================================================ FILE: packages/core/src/schema.ts ================================================ import { z } from "zod"; import type { EditMode } from "./edit-modes"; import { buildEditInstructions } from "./edit-modes"; /** * Schema builder primitives */ export interface SchemaBuilder { /** String type */ string(): SchemaType<"string">; /** Number type */ number(): SchemaType<"number">; /** Boolean type */ boolean(): SchemaType<"boolean">; /** Array of type */ array(item: T): SchemaType<"array", T>; /** Object with shape */ object>( shape: T, ): SchemaType<"object", T>; /** Record/map with value type */ record(value: T): SchemaType<"record", T>; /** Any type */ any(): SchemaType<"any">; /** Placeholder for user-provided Zod schema */ zod(): SchemaType<"zod">; /** Reference to catalog key (e.g., 'catalog.components') */ ref(path: string): SchemaType<"ref", string>; /** Props from referenced catalog entry */ propsOf(path: string): SchemaType<"propsOf", string>; /** Map of named entries with shared shape */ map>( entryShape: T, ): SchemaType<"map", T>; /** Optional modifier */ optional(): { optional: true }; } /** * Schema type representation */ export interface SchemaType { kind: TKind; inner?: TInner; optional?: boolean; } /** * Schema definition shape */ export interface SchemaDefinition< TSpec extends SchemaType = SchemaType, TCatalog extends SchemaType = SchemaType, > { /** What the AI-generated spec looks like */ spec: TSpec; /** What the catalog must provide */ catalog: TCatalog; } /** * Schema instance with methods */ export interface Schema { /** The schema definition */ readonly definition: TDef; /** Custom prompt template for this schema */ readonly promptTemplate?: PromptTemplate; /** Default rules baked into the schema (injected before customRules) */ readonly defaultRules?: string[]; /** Built-in actions always available at runtime (injected into prompts automatically) */ readonly builtInActions?: BuiltInAction[]; /** Create a catalog from this schema */ createCatalog>( catalog: TCatalog, ): Catalog; } /** * Catalog instance with methods */ export interface Catalog< TDef extends SchemaDefinition = SchemaDefinition, TCatalog = unknown, > { /** The schema this catalog is based on */ readonly schema: Schema; /** The catalog data */ readonly data: TCatalog; /** Component names */ readonly componentNames: string[]; /** Action names */ readonly actionNames: string[]; /** Generate system prompt for AI */ prompt(options?: PromptOptions): string; /** Export as JSON Schema for structured outputs */ jsonSchema(options?: JsonSchemaOptions): object; /** Validate a spec against this catalog */ validate(spec: unknown): SpecValidationResult>; /** Get the Zod schema for the spec */ zodSchema(): z.ZodType>; /** Type helper for the spec type */ readonly _specType: InferSpec; } /** * Options for JSON Schema export */ export interface JsonSchemaOptions { /** * When true, produces a strict JSON Schema subset compatible with * LLM structured output APIs (OpenAI, Google Gemini, Anthropic, etc.). * This ensures: * - `additionalProperties: false` on every object * - All object properties listed in `required` (optionals use nullable types) * - Record/map types converted to fixed-key objects * * **Limitation:** Record types (dynamic-key maps) cannot be represented in * strict JSON Schema because `additionalProperties` must be `false`. They * are emitted as `{ type: "object", properties: {}, additionalProperties: false }`. * The LLM prompt (via `catalog.prompt()`) still describes the full structure, * so the model can produce valid output even though the JSON Schema for * record entries is opaque. */ strict?: boolean; } /** * Prompt generation options */ export interface PromptOptions { /** Custom system message intro */ system?: string; /** Additional rules to append */ customRules?: string[]; /** * Output mode for the generated prompt. * * - `"standalone"` (default): The LLM should output only JSONL patches (no prose). * - `"inline"`: The LLM should respond conversationally first, then output JSONL patches. * Includes rules about interleaving text with JSONL and not wrapping in code fences. * * @deprecated `"generate"` — use `"standalone"` instead. * @deprecated `"chat"` — use `"inline"` instead. */ mode?: "standalone" | "inline" | "generate" | "chat"; /** Edit modes to document in the system prompt. Default: `["patch"]`. */ editModes?: EditMode[]; } /** * Context provided to prompt templates */ export interface PromptContext { /** The catalog data */ catalog: TCatalog; /** Component names from the catalog */ componentNames: string[]; /** Action names from the catalog (if any) */ actionNames: string[]; /** Prompt options provided by the user */ options: PromptOptions; /** Helper to format a Zod type as a human-readable string */ formatZodType: (schema: z.ZodType) => string; } /** * Prompt template function type */ export type PromptTemplate = ( context: PromptContext, ) => string; /** * A built-in action that is always available regardless of catalog configuration. * These are handled by the runtime (e.g. ActionProvider) and injected into prompts * automatically so the LLM knows about them. */ export interface BuiltInAction { /** Action name (e.g. "setState") */ name: string; /** Human-readable description for the LLM */ description: string; } /** * Schema options */ export interface SchemaOptions { /** Custom prompt template for this schema */ promptTemplate?: PromptTemplate; /** Default rules baked into the schema (injected before customRules in prompts) */ defaultRules?: string[]; /** * Built-in actions that are always available regardless of catalog configuration. * These are injected into prompts automatically so the LLM knows about them, * but they don't require handlers in defineRegistry because the runtime * (e.g. ActionProvider) handles them directly. */ builtInActions?: BuiltInAction[]; } /** * Spec validation result */ export interface SpecValidationResult { success: boolean; data?: T; error?: z.ZodError; } // ============================================================================= // Catalog Type Inference Helpers // ============================================================================= /** * Extract the components map type from a catalog * @example type Components = InferCatalogComponents; */ export type InferCatalogComponents = C extends Catalog ? TCatalog extends { components: infer Comps } ? Comps : never : never; /** * Extract the actions map type from a catalog * @example type Actions = InferCatalogActions; */ export type InferCatalogActions = C extends Catalog ? TCatalog extends { actions: infer Acts } ? Acts : never : never; /** * Infer component props from a catalog by component name * @example type ButtonProps = InferComponentProps; */ export type InferComponentProps< C extends Catalog, K extends keyof InferCatalogComponents, > = InferCatalogComponents[K] extends { props: z.ZodType } ? P : never; /** * Infer action params from a catalog by action name * @example type ViewCustomersParams = InferActionParams; */ export type InferActionParams< C extends Catalog, K extends keyof InferCatalogActions, > = InferCatalogActions[K] extends { params: z.ZodType } ? P : never; // ============================================================================= // Internal Type Inference Helpers // ============================================================================= export type InferCatalogInput = T extends SchemaType<"object", infer Shape> ? { [K in keyof Shape]: InferCatalogField } : never; type InferCatalogField = T extends SchemaType<"map", infer EntryShape> ? Record< string, // Only 'props' is required, rest are optional InferMapEntryRequired & Partial> > : T extends SchemaType<"zod"> ? z.ZodType : T extends SchemaType<"string"> ? string : T extends SchemaType<"number"> ? number : T extends SchemaType<"boolean"> ? boolean : T extends SchemaType<"array", infer Item> ? InferCatalogField[] : T extends SchemaType<"object", infer Shape> ? { [K in keyof Shape]: InferCatalogField } : unknown; // Extract required fields (props is always required) type InferMapEntryRequired = { [K in keyof T as K extends "props" ? K : never]: InferMapEntryField; }; // Extract optional fields (everything except props) type InferMapEntryOptional = { [K in keyof T as K extends "props" ? never : K]: InferMapEntryField; }; type InferMapEntryField = T extends SchemaType<"zod"> ? z.ZodType : T extends SchemaType<"string"> ? string : T extends SchemaType<"number"> ? number : T extends SchemaType<"boolean"> ? boolean : T extends SchemaType<"array", infer Item> ? InferMapEntryField[] : T extends SchemaType<"object", infer Shape> ? { [K in keyof Shape]: InferMapEntryField } : unknown; // Spec inference (simplified - will be expanded) export type InferSpec = TDef extends { spec: SchemaType<"object", infer Shape>; } ? InferSpecObject : unknown; type InferSpecObject = { [K in keyof Shape]: InferSpecField; }; type InferSpecField = T extends SchemaType<"string"> ? string : T extends SchemaType<"number"> ? number : T extends SchemaType<"boolean"> ? boolean : T extends SchemaType<"array", infer Item> ? InferSpecField[] : T extends SchemaType<"object", infer Shape> ? InferSpecObject : T extends SchemaType<"record", infer Value> ? Record> : T extends SchemaType<"ref", infer Path> ? InferRefType : T extends SchemaType<"propsOf", infer Path> ? InferPropsOfType : T extends SchemaType<"any"> ? unknown : unknown; type InferRefType = Path extends "catalog.components" ? TCatalog extends { components: infer C } ? keyof C : string : Path extends "catalog.actions" ? TCatalog extends { actions: infer A } ? keyof A : string : string; type InferPropsOfType = Path extends "catalog.components" ? TCatalog extends { components: infer C } ? C extends Record }> ? P : Record : Record : Record; /** * Create the schema builder */ function createBuilder(): SchemaBuilder { return { string: () => ({ kind: "string" }), number: () => ({ kind: "number" }), boolean: () => ({ kind: "boolean" }), array: (item) => ({ kind: "array", inner: item }), object: (shape) => ({ kind: "object", inner: shape }), record: (value) => ({ kind: "record", inner: value }), any: () => ({ kind: "any" }), zod: () => ({ kind: "zod" }), ref: (path) => ({ kind: "ref", inner: path }), propsOf: (path) => ({ kind: "propsOf", inner: path }), map: (entryShape) => ({ kind: "map", inner: entryShape }), optional: () => ({ optional: true }), }; } /** * Define a schema using the builder pattern */ export function defineSchema( builder: (s: SchemaBuilder) => TDef, options?: SchemaOptions, ): Schema { const s = createBuilder(); const definition = builder(s); return { definition, promptTemplate: options?.promptTemplate, defaultRules: options?.defaultRules, builtInActions: options?.builtInActions, createCatalog>( catalog: TCatalog, ): Catalog { return createCatalogFromSchema(this as Schema, catalog); }, }; } /** * Create a catalog from a schema (internal) */ function createCatalogFromSchema( schema: Schema, catalogData: TCatalog, ): Catalog { // Extract component and action names const components = (catalogData as Record).components as | Record | undefined; const actions = (catalogData as Record).actions as | Record | undefined; const componentNames = components ? Object.keys(components) : []; const actionNames = actions ? Object.keys(actions) : []; // Build the Zod schema for validation const zodSchema = buildZodSchemaFromDefinition( schema.definition, catalogData, ); return { schema, data: catalogData, componentNames, actionNames, prompt(options: PromptOptions = {}): string { return generatePrompt(this, options); }, jsonSchema(options: JsonSchemaOptions = {}): object { return zodToJsonSchema(zodSchema, options.strict ?? false); }, validate(spec: unknown): SpecValidationResult> { const result = zodSchema.safeParse(spec); if (result.success) { return { success: true, data: result.data as InferSpec, }; } return { success: false, error: result.error }; }, zodSchema(): z.ZodType> { return zodSchema as z.ZodType>; }, get _specType(): InferSpec { throw new Error("_specType is only for type inference"); }, }; } /** * Build Zod schema from schema definition */ function buildZodSchemaFromDefinition( definition: SchemaDefinition, catalogData: unknown, ): z.ZodType { return buildZodType(definition.spec, catalogData); } function buildZodType(schemaType: SchemaType, catalogData: unknown): z.ZodType { switch (schemaType.kind) { case "string": return z.string(); case "number": return z.number(); case "boolean": return z.boolean(); case "any": return z.any(); case "array": { const inner = buildZodType(schemaType.inner as SchemaType, catalogData); return z.array(inner); } case "object": { const shape = schemaType.inner as Record; const zodShape: Record = {}; for (const [key, value] of Object.entries(shape)) { let zodType = buildZodType(value, catalogData); if (value.optional) { zodType = zodType.optional(); } zodShape[key] = zodType; } return z.object(zodShape); } case "record": { const inner = buildZodType(schemaType.inner as SchemaType, catalogData); return z.record(z.string(), inner); } case "ref": { // Reference to catalog key - create enum of valid keys const path = schemaType.inner as string; const keys = getKeysFromPath(path, catalogData); if (keys.length === 0) { return z.string(); } if (keys.length === 1) { return z.literal(keys[0]!); } return z.enum(keys as [string, ...string[]]); } case "propsOf": { // Props from catalog entry - create union of all props schemas const path = schemaType.inner as string; const propsSchemas = getPropsFromPath(path, catalogData); if (propsSchemas.length === 0) { return z.record(z.string(), z.unknown()); } if (propsSchemas.length === 1) { return propsSchemas[0]!; } // For propsOf, we need to be lenient since type determines which props apply return z.record(z.string(), z.unknown()); } default: return z.unknown(); } } function getKeysFromPath(path: string, catalogData: unknown): string[] { const parts = path.split("."); let current: unknown = { catalog: catalogData }; for (const part of parts) { if (current && typeof current === "object") { current = (current as Record)[part]; } else { return []; } } if (current && typeof current === "object") { return Object.keys(current); } return []; } function getPropsFromPath(path: string, catalogData: unknown): z.ZodType[] { const parts = path.split("."); let current: unknown = { catalog: catalogData }; for (const part of parts) { if (current && typeof current === "object") { current = (current as Record)[part]; } else { return []; } } if (current && typeof current === "object") { return Object.values(current as Record) .map((entry) => entry.props) .filter((props): props is z.ZodType => props !== undefined); } return []; } /** * Generate system prompt from catalog */ function generatePrompt( catalog: Catalog, options: PromptOptions, ): string { // Check if schema has a custom prompt template if (catalog.schema.promptTemplate) { const context: PromptContext = { catalog: catalog.data, componentNames: catalog.componentNames, actionNames: catalog.actionNames, options, formatZodType, }; return catalog.schema.promptTemplate(context); } // Default JSONL element-tree format (for @json-render/react and similar) const { system = "You are a UI generator that outputs JSON.", customRules = [], mode: rawMode = "standalone", } = options; const mode: "standalone" | "inline" = rawMode === "chat" ? (console.warn( '[json-render] mode "chat" is deprecated, use "inline" instead', ), "inline") : rawMode === "generate" ? (console.warn( '[json-render] mode "generate" is deprecated, use "standalone" instead', ), "standalone") : rawMode; const lines: string[] = []; lines.push(system); lines.push(""); // Output format section - explain JSONL streaming patch format if (mode === "inline") { lines.push("OUTPUT FORMAT (text + JSONL, RFC 6902 JSON Patch):"); lines.push( "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.", ); lines.push( "The JSONL lines use RFC 6902 JSON Patch operations to build a UI tree. Always wrap them in a ```spec fence block:", ); lines.push(" ```spec"); lines.push(' {"op":"add","path":"/root","value":"main"}'); lines.push( ' {"op":"add","path":"/elements/main","value":{"type":"Card","props":{"title":"Hello"},"children":[]}}', ); lines.push(" ```"); lines.push( "If the user's message does not require a UI (e.g. a greeting or clarifying question), respond with text only — no JSONL.", ); } else { lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):"); lines.push( "Output JSONL (one JSON object per line) using RFC 6902 JSON Patch operations to build a UI tree.", ); } lines.push( "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.", ); lines.push(""); lines.push("Example output (each line is a separate JSON object):"); lines.push(""); // Build example using actual catalog component names and props to avoid hallucinations const allComponents = (catalog.data as Record).components as | Record | undefined; const cn = catalog.componentNames; const comp1 = cn[0] || "Component"; const comp2 = cn.length > 1 ? cn[1]! : comp1; const comp1Def = allComponents?.[comp1]; const comp2Def = allComponents?.[comp2]; const comp1Props = comp1Def ? getExampleProps(comp1Def) : {}; const comp2Props = comp2Def ? getExampleProps(comp2Def) : {}; // Find a string prop on comp2 to demonstrate $state dynamic bindings const dynamicPropName = comp2Def?.props ? findFirstStringProp(comp2Def.props) : null; const dynamicProps = dynamicPropName ? { ...comp2Props, [dynamicPropName]: { $item: "title" } } : comp2Props; const exampleOutput = [ JSON.stringify({ op: "add", path: "/root", value: "main" }), JSON.stringify({ op: "add", path: "/elements/main", value: { type: comp1, props: comp1Props, children: ["child-1", "list"], }, }), JSON.stringify({ op: "add", path: "/elements/child-1", value: { type: comp2, props: comp2Props, children: [] }, }), JSON.stringify({ op: "add", path: "/elements/list", value: { type: comp1, props: comp1Props, repeat: { statePath: "/items", key: "id" }, children: ["item"], }, }), JSON.stringify({ op: "add", path: "/elements/item", value: { type: comp2, props: dynamicProps, children: [] }, }), JSON.stringify({ op: "add", path: "/state/items", value: [] }), JSON.stringify({ op: "add", path: "/state/items/0", value: { id: "1", title: "First Item" }, }), JSON.stringify({ op: "add", path: "/state/items/1", value: { id: "2", title: "Second Item" }, }), ].join("\n"); lines.push(`${exampleOutput} 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.`); lines.push(""); // Initial state section lines.push("INITIAL STATE:"); lines.push( "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.", ); lines.push( "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.", ); lines.push( "Output state patches right after the elements that reference them, so the UI fills in progressively as it streams.", ); lines.push( "Stream state progressively - output one patch per array item instead of one giant blob:", ); lines.push( ' For arrays: {"op":"add","path":"/state/posts/0","value":{"id":"1","title":"First Post",...}} then /state/posts/1, /state/posts/2, etc.', ); lines.push( ' For scalars: {"op":"add","path":"/state/newTodoText","value":""}', ); lines.push( ' Initialize the array first if needed: {"op":"add","path":"/state/posts","value":[]}', ); lines.push( '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.', ); lines.push( "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.", ); lines.push(""); lines.push("DYNAMIC LISTS (repeat field):"); lines.push( '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" } }.', ); lines.push( '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.', ); lines.push( `Example: ${JSON.stringify({ type: comp1, props: comp1Props, repeat: { statePath: "/todos", key: "id" }, children: ["todo-item"] })}`, ); lines.push( '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.', ); lines.push( "ALWAYS use the repeat field for lists backed by state arrays. NEVER hardcode individual elements for each array item.", ); lines.push( 'IMPORTANT: "repeat" is a top-level field on the element (sibling of type/props/children), NOT inside props.', ); lines.push(""); lines.push("ARRAY STATE ACTIONS:"); lines.push( 'Use action "pushState" to append items to arrays. Params: { statePath: "/arrayPath", value: { ...item }, clearStatePath: "/inputPath" }.', ); lines.push( 'Values inside pushState can contain { "$state": "/statePath" } references to read current state (e.g. the text from an input field).', ); lines.push( 'Use "$id" inside a pushState value to auto-generate a unique ID.', ); lines.push( 'Example: on: { "press": { "action": "pushState", "params": { "statePath": "/todos", "value": { "id": "$id", "title": { "$state": "/newTodoText" }, "completed": false }, "clearStatePath": "/newTodoText" } } }', ); lines.push( '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.', ); lines.push( "For lists where users can add/remove items (todos, carts, etc.), use pushState and removeState instead of hardcoding with setState.", ); lines.push(""); lines.push( '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.', ); lines.push(""); // Components section — reuse the typed reference from example generation const components = allComponents; if (components) { lines.push(`AVAILABLE COMPONENTS (${catalog.componentNames.length}):`); lines.push(""); for (const [name, def] of Object.entries(components)) { const propsStr = def.props ? formatZodType(def.props) : "{}"; const hasChildren = def.slots && def.slots.length > 0; const childrenStr = hasChildren ? " [accepts children]" : ""; const eventsStr = def.events && def.events.length > 0 ? ` [events: ${def.events.join(", ")}]` : ""; const descStr = def.description ? ` - ${def.description}` : ""; lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`); } lines.push(""); } // Actions section const actions = (catalog.data as Record).actions as | Record | undefined; const builtInActions = catalog.schema.builtInActions ?? []; const hasCustomActions = actions && catalog.actionNames.length > 0; const hasBuiltInActions = builtInActions.length > 0; if (hasCustomActions || hasBuiltInActions) { lines.push("AVAILABLE ACTIONS:"); lines.push(""); // Built-in actions (handled by runtime, always available) for (const action of builtInActions) { lines.push(`- ${action.name}: ${action.description} [built-in]`); } // Custom actions (declared in catalog, require handlers) if (hasCustomActions) { for (const [name, def] of Object.entries(actions)) { lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`); } } lines.push(""); } // Events section lines.push("EVENTS (the `on` field):"); lines.push( "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.", ); lines.push( 'Each key in `on` is an event name (from the component\'s supported events), and the value is an action binding: `{ "action": "", "params": { ... } }`.', ); lines.push(""); lines.push("Example:"); lines.push( ` ${JSON.stringify({ type: comp1, props: comp1Props, on: { press: { action: "setState", params: { statePath: "/saved", value: true } } }, children: [] })}`, ); lines.push(""); lines.push( 'Action params can use dynamic references to read from state: { "$state": "/statePath" }.', ); lines.push( "IMPORTANT: Do NOT put action/actionParams inside props. Always use the `on` field for event bindings.", ); lines.push(""); // Visibility conditions lines.push("VISIBILITY CONDITIONS:"); lines.push( "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.", ); lines.push( `Correct: ${JSON.stringify({ type: comp1, props: comp1Props, visible: { $state: "/activeTab", eq: "home" }, children: ["..."] })}`, ); lines.push( '- `{ "$state": "/path" }` - visible when state at path is truthy', ); lines.push( '- `{ "$state": "/path", "not": true }` - visible when state at path is falsy', ); lines.push( '- `{ "$state": "/path", "eq": "value" }` - visible when state equals value', ); lines.push( '- `{ "$state": "/path", "neq": "value" }` - visible when state does not equal value', ); lines.push( '- `{ "$state": "/path", "gt": N }` / `gte` / `lt` / `lte` - numeric comparisons', ); lines.push( "- Use ONE operator per condition (eq, neq, gt, gte, lt, lte). Do not combine multiple operators.", ); lines.push('- Any condition can add `"not": true` to invert its result'); lines.push( "- `[condition, condition]` - all conditions must be true (implicit AND)", ); lines.push( '- `{ "$and": [condition, condition] }` - explicit AND (use when nesting inside $or)', ); lines.push( '- `{ "$or": [condition, condition] }` - at least one must be true (OR)', ); lines.push("- `true` / `false` - always visible/hidden"); lines.push(""); lines.push( "Use a component with on.press bound to setState to update state and drive visibility.", ); lines.push( `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.`, ); lines.push(""); lines.push( '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.', ); lines.push(""); // Dynamic prop expressions lines.push("DYNAMIC PROPS:"); lines.push( "Any prop value can be a dynamic expression that resolves based on state. Three forms are supported:", ); lines.push(""); lines.push( '1. Read-only state: `{ "$state": "/statePath" }` - resolves to the value at that state path (one-way read).', ); lines.push( ' Example: `"color": { "$state": "/theme/primary" }` reads the color from state.', ); lines.push(""); lines.push( '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.).', ); lines.push( ' Example: `"value": { "$bindState": "/form/email" }` binds the input value to /form/email.', ); lines.push( ' Inside repeat scopes: `"checked": { "$bindItem": "completed" }` binds to the current item\'s completed field.', ); lines.push(""); lines.push( '3. Conditional: `{ "$cond": , "$then": , "$else": }` - evaluates the condition (same syntax as visibility conditions) and picks the matching value.', ); lines.push( ' Example: `"color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }`', ); lines.push(""); lines.push( "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.", ); lines.push(""); lines.push( '4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.', ); lines.push( ' 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.', ); lines.push(""); // $computed section — only emit when catalog defines functions const catalogFunctions = (catalog.data as Record).functions; if (catalogFunctions && Object.keys(catalogFunctions).length > 0) { lines.push( '5. Computed: `{ "$computed": "", "args": { "key": } }` - calls a registered function with resolved args and returns the result.', ); lines.push( ' Example: `"value": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } }`', ); lines.push(" Available functions:"); for (const name of Object.keys( catalogFunctions as Record, )) { lines.push(` - ${name}`); } lines.push(""); } // Validation section — only emit when at least one component has a `checks` prop const hasChecksComponents = allComponents ? Object.entries(allComponents).some(([, def]) => { if (!def.props) return false; const formatted = formatZodType(def.props); return formatted.includes("checks"); }) : false; if (hasChecksComponents) { lines.push("VALIDATION:"); lines.push( "Form components that accept a `checks` prop support client-side validation.", ); lines.push( 'Each check is an object: { "type": "", "message": "...", "args": { ... } }', ); lines.push(""); lines.push("Built-in validation types:"); lines.push(" - required — value must be non-empty"); lines.push(" - email — valid email format"); lines.push(' - minLength — minimum string length (args: { "min": N })'); lines.push(' - maxLength — maximum string length (args: { "max": N })'); lines.push(' - pattern — match a regex (args: { "pattern": "regex" })'); lines.push(' - min — minimum numeric value (args: { "min": N })'); lines.push(' - max — maximum numeric value (args: { "max": N })'); lines.push(" - numeric — value must be a number"); lines.push(" - url — valid URL format"); lines.push( ' - matches — must equal another field (args: { "other": { "$state": "/path" } })', ); lines.push( ' - equalTo — alias for matches (args: { "other": { "$state": "/path" } })', ); lines.push( ' - lessThan — value must be less than another field (args: { "other": { "$state": "/path" } })', ); lines.push( ' - greaterThan — value must be greater than another field (args: { "other": { "$state": "/path" } })', ); lines.push( ' - requiredIf — required only when another field is truthy (args: { "field": { "$state": "/path" } })', ); lines.push(""); lines.push("Example:"); lines.push( ' "checks": [{ "type": "required", "message": "Email is required" }, { "type": "email", "message": "Invalid email" }]', ); lines.push(""); lines.push( "IMPORTANT: When using checks, the component must also have a { $bindState } or { $bindItem } on its value/checked prop for two-way binding.", ); lines.push( "Always include validation checks on form inputs for a good user experience (e.g. required, email, minLength).", ); lines.push(""); } // State watchers section — only emit when actions are available (watchers // trigger actions, so the section is irrelevant without them). if (hasCustomActions || hasBuiltInActions) { lines.push("STATE WATCHERS:"); lines.push( "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.", ); lines.push( "Maps state paths (JSON Pointers) to action bindings. When the value at a watched path changes, the bound actions fire automatically.", ); lines.push(""); lines.push( "Example (cascading select — country changes trigger city loading):", ); lines.push( ` ${JSON.stringify({ type: "Select", props: { value: { $bindState: "/form/country" }, options: ["US", "Canada", "UK"] }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } } } }, children: [] })}`, ); lines.push(""); lines.push( "Use `watch` for cascading dependencies where changing one field should trigger side effects (loading data, resetting dependent fields, computing derived values).", ); lines.push( "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.", ); lines.push(""); } // Edit modes const editModes = options.editModes; if (editModes && editModes.length > 0) { lines.push(buildEditInstructions({ modes: editModes }, "json")); } // Rules lines.push("RULES:"); const baseRules = mode === "inline" ? [ "When generating UI, wrap all JSONL patches in a ```spec code fence - one JSON object per line inside the fence", "Write a brief conversational response before any JSONL output", 'First set root: {"op":"add","path":"/root","value":""}', 'Then add each element: {"op":"add","path":"/elements/","value":{...}}', "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.", "ONLY use components listed above", "Each element value needs: type, props, children (array of child keys)", "Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')", ] : [ "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences", 'First set root: {"op":"add","path":"/root","value":""}', 'Then add each element: {"op":"add","path":"/elements/","value":{...}}', "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.", "ONLY use components listed above", "Each element value needs: type, props, children (array of child keys)", "Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')", ]; const schemaRules = catalog.schema.defaultRules ?? []; const allRules = [...baseRules, ...schemaRules, ...customRules]; allRules.forEach((rule, i) => { lines.push(`${i + 1}. ${rule}`); }); return lines.join("\n"); } // ============================================================================= // Example Value Generation from Zod Schemas // ============================================================================= /** * Component definition shape as it appears in catalog data */ interface CatalogComponentDef { props?: z.ZodType; description?: string; slots?: string[]; events?: string[]; example?: Record; } /** * Get example props for a catalog component. * Uses the explicit `example` field if provided, otherwise generates from Zod schema. */ function getExampleProps(def: CatalogComponentDef): Record { if (def.example && Object.keys(def.example).length > 0) { return def.example; } if (def.props) { return generateExamplePropsFromZod(def.props); } return {}; } /** * Generate example prop values from a Zod object schema. * Only includes required fields to keep examples concise. */ function generateExamplePropsFromZod( schema: z.ZodType, ): Record { if (!schema || !schema._def) return {}; const def = schema._def as unknown as Record; const typeName = getZodTypeName(schema); if (typeName !== "ZodObject" && typeName !== "object") return {}; const shape = typeof def.shape === "function" ? (def.shape as () => Record)() : (def.shape as Record); if (!shape) return {}; const result: Record = {}; for (const [key, value] of Object.entries(shape)) { const innerTypeName = getZodTypeName(value); // Skip optional props to keep examples concise if ( innerTypeName === "ZodOptional" || innerTypeName === "optional" || innerTypeName === "ZodNullable" || innerTypeName === "nullable" ) { continue; } result[key] = generateExampleValue(value); } return result; } /** * Generate a single example value from a Zod type. */ function generateExampleValue(schema: z.ZodType): unknown { if (!schema || !schema._def) return "..."; const def = schema._def as unknown as Record; const typeName = getZodTypeName(schema); switch (typeName) { case "ZodString": case "string": return "example"; case "ZodNumber": case "number": return 0; case "ZodBoolean": case "boolean": return true; case "ZodLiteral": case "literal": return def.value; case "ZodEnum": case "enum": { if (Array.isArray(def.values) && def.values.length > 0) return def.values[0]; if (def.entries && typeof def.entries === "object") { const values = Object.values(def.entries as Record); return values.length > 0 ? values[0] : "example"; } return "example"; } case "ZodOptional": case "optional": case "ZodNullable": case "nullable": case "ZodDefault": case "default": { const inner = (def.innerType as z.ZodType) ?? (def.wrapped as z.ZodType); return inner ? generateExampleValue(inner) : null; } case "ZodArray": case "array": return []; case "ZodObject": case "object": return generateExamplePropsFromZod(schema); case "ZodUnion": case "union": { const options = def.options as z.ZodType[] | undefined; return options && options.length > 0 ? generateExampleValue(options[0]!) : "..."; } default: return "..."; } } /** * Find the name of the first required string prop in a Zod object schema. * Used to demonstrate $state dynamic bindings in examples. */ function findFirstStringProp(schema?: z.ZodType): string | null { if (!schema || !schema._def) return null; const def = schema._def as unknown as Record; const typeName = getZodTypeName(schema); if (typeName !== "ZodObject" && typeName !== "object") return null; const shape = typeof def.shape === "function" ? (def.shape as () => Record)() : (def.shape as Record); if (!shape) return null; for (const [key, value] of Object.entries(shape)) { const innerTypeName = getZodTypeName(value); // Skip optional props if ( innerTypeName === "ZodOptional" || innerTypeName === "optional" || innerTypeName === "ZodNullable" || innerTypeName === "nullable" ) { continue; } // Unwrap to check the actual type if (innerTypeName === "ZodString" || innerTypeName === "string") { return key; } } return null; } // ============================================================================= // Zod Introspection Helpers // ============================================================================= /** * Get Zod type name from schema (handles different Zod versions) */ function getZodTypeName(schema: z.ZodType): string { if (!schema || !schema._def) return ""; const def = schema._def as unknown as Record; // Zod 4+ uses _def.type, older versions use _def.typeName return (def.typeName as string) ?? (def.type as string) ?? ""; } /** * Format a Zod type into a human-readable string */ function formatZodType(schema: z.ZodType): string { if (!schema || !schema._def) return "unknown"; const def = schema._def as unknown as Record; const typeName = getZodTypeName(schema); switch (typeName) { case "ZodString": case "string": return "string"; case "ZodNumber": case "number": return "number"; case "ZodBoolean": case "boolean": return "boolean"; case "ZodLiteral": case "literal": return JSON.stringify(def.value); case "ZodEnum": case "enum": { // Zod 3 uses values array, Zod 4 uses entries object let values: string[]; if (Array.isArray(def.values)) { values = def.values as string[]; } else if (def.entries && typeof def.entries === "object") { values = Object.values(def.entries as Record); } else { return "enum"; } return values.map((v) => `"${v}"`).join(" | "); } case "ZodArray": case "array": { // safely resolve inner type for Zod arrays const inner = ( typeof def.element === "object" ? def.element : typeof def.type === "object" ? def.type : undefined ) as z.ZodType | undefined; return inner ? `Array<${formatZodType(inner)}>` : "Array"; } case "ZodObject": case "object": { // Shape can be a function (Zod 3) or direct object (Zod 4) const shape = typeof def.shape === "function" ? (def.shape as () => Record)() : (def.shape as Record); if (!shape) return "object"; const props = Object.entries(shape) .map(([key, value]) => { const innerTypeName = getZodTypeName(value); const isOptional = innerTypeName === "ZodOptional" || innerTypeName === "ZodNullable" || innerTypeName === "optional" || innerTypeName === "nullable"; return `${key}${isOptional ? "?" : ""}: ${formatZodType(value)}`; }) .join(", "); return `{ ${props} }`; } case "ZodOptional": case "optional": case "ZodNullable": case "nullable": { const inner = (def.innerType as z.ZodType) ?? (def.wrapped as z.ZodType); return inner ? formatZodType(inner) : "unknown"; } case "ZodUnion": case "union": { const options = def.options as z.ZodType[] | undefined; return options ? options.map((opt) => formatZodType(opt)).join(" | ") : "unknown"; } default: return "unknown"; } } /** * Resolve the Zod type name from a schema's internal definition. * Supports both Zod 3 (`_def.typeName`) and Zod 4 (`_def.type`). */ function zodTypeName(def: Record): string { // Zod 4 uses _def.type as a plain string (e.g. "string", "object") if (typeof def.type === "string") return def.type; // Zod 3 uses _def.typeName (e.g. "ZodString", "ZodObject") if (typeof def.typeName === "string") return def.typeName; return ""; } /** * Normalise a Zod type name to a canonical lowercase form. * Handles both Zod 3 ("ZodString") and Zod 4 ("string") conventions. */ function normalizeTypeName(raw: string): string { // Zod 3 names start with "Zod", e.g. "ZodString" → "string" if (raw.startsWith("Zod")) { return raw.slice(3).toLowerCase(); } return raw.toLowerCase(); } /** * Convert Zod schema to JSON Schema. * * When `strict` is true the output conforms to the JSON Schema subset required * by LLM structured output APIs (no `propertyNames`, `additionalProperties: false` * everywhere, all properties listed in `required`). */ function zodToJsonSchema(schema: z.ZodType, strict = false): object { const def = schema._def as unknown as Record; const kind = normalizeTypeName(zodTypeName(def)); switch (kind) { case "string": return { type: "string" }; case "number": return { type: "number" }; case "boolean": return { type: "boolean" }; case "literal": { // Zod 4: _def.values (array), Zod 3: _def.value (single) const values = def.values as unknown[] | undefined; const value = values ? values[0] : def.value; return { const: value }; } case "enum": { // Zod 4: _def.entries (object { a:"a", b:"b" }), Zod 3: _def.values (string[]) const entries = def.entries as Record | undefined; const values = entries ? Object.values(entries) : (def.values as string[] | undefined); return { enum: values ?? [] }; } case "array": { // Zod 4: _def.element, Zod 3: _def.type const inner = (def.element ?? def.type) as z.ZodType | undefined; return { type: "array", items: inner ? zodToJsonSchema(inner, strict) : {}, }; } case "object": { // Zod 4: _def.shape is an object, Zod 3: _def.shape is a function const rawShape = def.shape; const shape: Record | undefined = typeof rawShape === "function" ? (rawShape as () => Record)() : (rawShape as Record | undefined); if (!shape) { if (strict) { return { type: "object", properties: {}, required: [], additionalProperties: false, }; } return { type: "object" }; } const properties: Record = {}; const required: string[] = []; for (const [key, value] of Object.entries(shape)) { const innerDef = value._def as unknown as Record; const innerKind = normalizeTypeName(zodTypeName(innerDef)); const isOptional = innerKind === "optional" || innerKind === "nullable"; if (strict) { // In strict mode, all properties must be in required. // Optional properties are represented as nullable types. required.push(key); if (isOptional) { const unwrapped = zodToJsonSchema(value, strict); properties[key] = { anyOf: [unwrapped, { type: "null" }] }; } else { properties[key] = zodToJsonSchema(value, strict); } } else { properties[key] = zodToJsonSchema(value); if (!isOptional) { required.push(key); } } } return { type: "object", properties, required: required.length > 0 ? required : undefined, additionalProperties: false, }; } case "record": { const valueType = def.valueType as z.ZodType | undefined; if (strict) { // LLM strict schemas require `additionalProperties: false` and do not // permit a schema value for `additionalProperties`. Since record types // have dynamic keys that cannot be enumerated at schema-generation time, // we emit an opaque object. The LLM prompt still describes the expected // structure so the model can produce valid output. return { type: "object", properties: {}, required: [], additionalProperties: false, }; } return { type: "object", additionalProperties: valueType ? zodToJsonSchema(valueType) : true, }; } case "optional": case "nullable": { const inner = def.innerType as z.ZodType | undefined; return inner ? zodToJsonSchema(inner, strict) : {}; } case "union": { const options = def.options as z.ZodType[] | undefined; return options ? { anyOf: options.map((o) => zodToJsonSchema(o, strict)) } : {}; } case "any": case "unknown": if (strict) { return { type: "object", properties: {}, required: [], additionalProperties: false, }; } return {}; default: return {}; } } /** * Shorthand: Define a catalog directly from a schema */ export function defineCatalog< TDef extends SchemaDefinition, TCatalog extends InferCatalogInput, >(schema: Schema, catalog: TCatalog): Catalog { return schema.createCatalog(catalog); } ================================================ FILE: packages/core/src/spec-validator.test.ts ================================================ import { describe, it, expect } from "vitest"; import type { Spec } from "./types"; import { validateSpec, autoFixSpec } from "./spec-validator"; // ============================================================================= // validateSpec // ============================================================================= describe("validateSpec", () => { it("returns valid for a correct spec", () => { const spec: Spec = { root: "root", elements: { root: { type: "Stack", props: {}, children: ["child1"] }, child1: { type: "Text", props: { text: "hello" }, children: [] }, }, }; const result = validateSpec(spec); expect(result.valid).toBe(true); expect(result.issues).toHaveLength(0); }); it("detects missing root", () => { const spec = { root: "", elements: { a: { type: "T", props: {}, children: [] } }, } as Spec; const result = validateSpec(spec); expect(result.valid).toBe(false); expect(result.issues.some((i) => i.code === "missing_root")).toBe(true); }); it("detects root_not_found", () => { const spec: Spec = { root: "missing", elements: { a: { type: "T", props: {}, children: [] } }, }; const result = validateSpec(spec); expect(result.valid).toBe(false); expect(result.issues.some((i) => i.code === "root_not_found")).toBe(true); }); it("detects empty spec", () => { const spec: Spec = { root: "r", elements: {} }; const result = validateSpec(spec); expect(result.valid).toBe(false); expect(result.issues.some((i) => i.code === "empty_spec")).toBe(true); }); it("detects missing_child", () => { const spec: Spec = { root: "root", elements: { root: { type: "Stack", props: {}, children: ["nonexistent"] }, }, }; const result = validateSpec(spec); expect(result.valid).toBe(false); expect(result.issues.some((i) => i.code === "missing_child")).toBe(true); }); it("detects visible_in_props", () => { const spec: Spec = { root: "root", elements: { root: { type: "Text", props: { visible: { $state: "/show" } }, children: [], }, }, }; const result = validateSpec(spec); expect(result.valid).toBe(false); expect(result.issues.some((i) => i.code === "visible_in_props")).toBe(true); }); it("detects on_in_props", () => { const spec: Spec = { root: "root", elements: { root: { type: "Button", props: { on: { press: { action: "doSomething" } } }, children: [], }, }, }; const result = validateSpec(spec); expect(result.valid).toBe(false); expect(result.issues.some((i) => i.code === "on_in_props")).toBe(true); }); it("detects repeat_in_props", () => { const spec: Spec = { root: "root", elements: { root: { type: "Stack", props: { repeat: { statePath: "/items" } }, children: [], }, }, }; const result = validateSpec(spec); expect(result.valid).toBe(false); expect(result.issues.some((i) => i.code === "repeat_in_props")).toBe(true); }); it("detects watch_in_props", () => { const spec: Spec = { root: "root", elements: { root: { type: "Select", props: { watch: { "/form/country": { action: "loadCities" }, }, }, children: [], }, }, }; const result = validateSpec(spec); expect(result.valid).toBe(false); const watchIssue = result.issues.find((i) => i.code === "watch_in_props"); expect(watchIssue).toBeDefined(); expect(watchIssue!.elementKey).toBe("root"); }); it("detects orphaned elements when checkOrphans is true", () => { const spec: Spec = { root: "root", elements: { root: { type: "Stack", props: {}, children: [] }, orphan: { type: "Text", props: {}, children: [] }, }, }; const result = validateSpec(spec, { checkOrphans: true }); expect(result.valid).toBe(true); expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(true); }); }); // ============================================================================= // autoFixSpec // ============================================================================= describe("autoFixSpec", () => { it("moves visible from props to element level", () => { const spec: Spec = { root: "root", elements: { root: { type: "Text", props: { text: "hi", visible: { $state: "/show" } }, children: [], }, }, }; const { spec: fixed, fixes } = autoFixSpec(spec); expect( (fixed.elements.root.props as Record).visible, ).toBeUndefined(); expect(fixed.elements.root.visible).toEqual({ $state: "/show" }); expect(fixes.some((f) => f.includes("visible"))).toBe(true); }); it("moves on from props to element level", () => { const spec: Spec = { root: "root", elements: { root: { type: "Button", props: { label: "OK", on: { press: { action: "submit" } } }, children: [], }, }, }; const { spec: fixed, fixes } = autoFixSpec(spec); expect( (fixed.elements.root.props as Record).on, ).toBeUndefined(); expect(fixed.elements.root.on).toEqual({ press: { action: "submit" } }); expect(fixes.some((f) => f.includes('"on"'))).toBe(true); }); it("moves repeat from props to element level", () => { const spec: Spec = { root: "root", elements: { root: { type: "Stack", props: { repeat: { statePath: "/items" } }, children: ["child"], }, child: { type: "Text", props: {}, children: [] }, }, }; const { spec: fixed, fixes } = autoFixSpec(spec); expect( (fixed.elements.root.props as Record).repeat, ).toBeUndefined(); expect(fixed.elements.root.repeat).toEqual({ statePath: "/items" }); expect(fixes.some((f) => f.includes('"repeat"'))).toBe(true); }); it("moves watch from props to element level", () => { const spec: Spec = { root: "root", elements: { root: { type: "Select", props: { label: "Country", watch: { "/form/country": { action: "loadCities" }, }, }, children: [], }, }, }; const { spec: fixed, fixes } = autoFixSpec(spec); expect( (fixed.elements.root.props as Record).watch, ).toBeUndefined(); expect(fixed.elements.root.watch).toEqual({ "/form/country": { action: "loadCities" }, }); expect(fixes.some((f) => f.includes('"watch"'))).toBe(true); }); it("returns no fixes for a correct spec", () => { const spec: Spec = { root: "root", elements: { root: { type: "Stack", props: { direction: "vertical" }, children: [], watch: { "/x": { action: "y" } }, }, }, }; const { fixes } = autoFixSpec(spec); expect(fixes).toHaveLength(0); }); }); ================================================ FILE: packages/core/src/spec-validator.ts ================================================ import type { Spec, UIElement } from "./types"; // ============================================================================= // Spec Structural Validation // ============================================================================= /** * Severity level for validation issues. */ export type SpecIssueSeverity = "error" | "warning"; /** * A single validation issue found in a spec. */ export interface SpecIssue { /** Severity: errors should be fixed, warnings are informational */ severity: SpecIssueSeverity; /** Human-readable description of the issue */ message: string; /** The element key where the issue was found (if applicable) */ elementKey?: string; /** Machine-readable issue code for programmatic handling */ code: | "missing_root" | "root_not_found" | "missing_child" | "visible_in_props" | "orphaned_element" | "empty_spec" | "on_in_props" | "repeat_in_props" | "watch_in_props"; } /** * Result of spec structural validation. */ export interface SpecValidationIssues { /** Whether the spec passed validation (no errors; warnings are OK) */ valid: boolean; /** List of issues found */ issues: SpecIssue[]; } /** * Options for validateSpec. */ export interface ValidateSpecOptions { /** * Whether to check for orphaned elements (elements not reachable from root). * Defaults to false since orphans are harmless (just unused). */ checkOrphans?: boolean; } /** * Validate a spec for structural integrity. * * Checks for common AI-generation errors: * - Missing or empty root * - Root element not found in elements map * - Children referencing non-existent elements * - `visible` placed inside `props` instead of on the element * - Orphaned elements (optional) * * @example * ```ts * const result = validateSpec(spec); * if (!result.valid) { * console.log("Spec errors:", result.issues); * } * ``` */ export function validateSpec( spec: Spec, options: ValidateSpecOptions = {}, ): SpecValidationIssues { const { checkOrphans = false } = options; const issues: SpecIssue[] = []; // 1. Check root if (!spec.root) { issues.push({ severity: "error", message: "Spec has no root element defined.", code: "missing_root", }); return { valid: false, issues }; } if (!spec.elements[spec.root]) { issues.push({ severity: "error", message: `Root element "${spec.root}" not found in elements map.`, code: "root_not_found", }); } // 2. Check for empty spec if (Object.keys(spec.elements).length === 0) { issues.push({ severity: "error", message: "Spec has no elements.", code: "empty_spec", }); return { valid: false, issues }; } // 3. Check each element for (const [key, element] of Object.entries(spec.elements)) { // 3a. Missing children if (element.children) { for (const childKey of element.children) { if (!spec.elements[childKey]) { issues.push({ severity: "error", message: `Element "${key}" references child "${childKey}" which does not exist in the elements map.`, elementKey: key, code: "missing_child", }); } } } // 3b. `visible` inside props const props = element.props as Record | undefined; if (props && "visible" in props && props.visible !== undefined) { issues.push({ severity: "error", message: `Element "${key}" has "visible" inside "props". It should be a top-level field on the element (sibling of type/props/children).`, elementKey: key, code: "visible_in_props", }); } // 3c. `on` inside props (should be a top-level field) if (props && "on" in props && props.on !== undefined) { issues.push({ severity: "error", message: `Element "${key}" has "on" inside "props". It should be a top-level field on the element (sibling of type/props/children).`, elementKey: key, code: "on_in_props", }); } // 3d. `repeat` inside props (should be a top-level field) if (props && "repeat" in props && props.repeat !== undefined) { issues.push({ severity: "error", message: `Element "${key}" has "repeat" inside "props". It should be a top-level field on the element (sibling of type/props/children).`, elementKey: key, code: "repeat_in_props", }); } // 3e. `watch` inside props (should be a top-level field) if (props && "watch" in props && props.watch !== undefined) { issues.push({ severity: "error", message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`, elementKey: key, code: "watch_in_props", }); } } // 4. Orphaned elements (optional) if (checkOrphans) { const reachable = new Set(); const walk = (key: string) => { if (reachable.has(key)) return; reachable.add(key); const el = spec.elements[key]; if (el?.children) { for (const childKey of el.children) { if (spec.elements[childKey]) { walk(childKey); } } } }; if (spec.elements[spec.root]) { walk(spec.root); } for (const key of Object.keys(spec.elements)) { if (!reachable.has(key)) { issues.push({ severity: "warning", message: `Element "${key}" is not reachable from root "${spec.root}".`, elementKey: key, code: "orphaned_element", }); } } } const hasErrors = issues.some((i) => i.severity === "error"); return { valid: !hasErrors, issues }; } /** * Auto-fix common spec issues in-place and return a corrected copy. * * Currently fixes: * - `visible` inside `props` → moved to element level * - `on` inside `props` → moved to element level * - `repeat` inside `props` → moved to element level * * Returns the fixed spec and a list of fixes applied. */ export function autoFixSpec(spec: Spec): { spec: Spec; fixes: string[]; } { const fixes: string[] = []; const fixedElements: Record = {}; for (const [key, element] of Object.entries(spec.elements)) { const props = element.props as Record | undefined; let fixed = element; if (props && "visible" in props && props.visible !== undefined) { // Move visible from props to element level const { visible, ...restProps } = fixed.props as Record; fixed = { ...fixed, props: restProps, visible: visible as UIElement["visible"], }; fixes.push(`Moved "visible" from props to element level on "${key}".`); } let currentProps = fixed.props as Record | undefined; if (currentProps && "on" in currentProps && currentProps.on !== undefined) { // Move on from props to element level const { on, ...restProps } = currentProps; fixed = { ...fixed, props: restProps, on: on as UIElement["on"], }; fixes.push(`Moved "on" from props to element level on "${key}".`); } currentProps = fixed.props as Record | undefined; if ( currentProps && "repeat" in currentProps && currentProps.repeat !== undefined ) { // Move repeat from props to element level const { repeat, ...restProps } = currentProps; fixed = { ...fixed, props: restProps, repeat: repeat as UIElement["repeat"], }; fixes.push(`Moved "repeat" from props to element level on "${key}".`); } currentProps = fixed.props as Record | undefined; if ( currentProps && "watch" in currentProps && currentProps.watch !== undefined ) { const { watch, ...restProps } = currentProps; fixed = { ...fixed, props: restProps, watch: watch as UIElement["watch"], }; fixes.push(`Moved "watch" from props to element level on "${key}".`); } fixedElements[key] = fixed; } return { spec: { root: spec.root, elements: fixedElements, state: spec.state }, fixes, }; } /** * Format validation issues into a human-readable string suitable for * inclusion in a repair prompt sent back to the AI. */ export function formatSpecIssues(issues: SpecIssue[]): string { const errors = issues.filter((i) => i.severity === "error"); if (errors.length === 0) return ""; const lines = ["The generated UI spec has the following errors:"]; for (const issue of errors) { lines.push(`- ${issue.message}`); } return lines.join("\n"); } ================================================ FILE: packages/core/src/state-store.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { createStateStore, flattenToPointers } from "./state-store"; describe("createStateStore", () => { it("creates a store with initial state", () => { const store = createStateStore({ name: "test" }); expect(store.getSnapshot()).toEqual({ name: "test" }); expect(store.get("/name")).toBe("test"); }); it("set notifies subscribers", () => { const store = createStateStore({}); const listener = vi.fn(); store.subscribe(listener); store.set("/x", 1); expect(listener).toHaveBeenCalledTimes(1); expect(store.get("/x")).toBe(1); }); it("set skips notification when value is unchanged", () => { const store = createStateStore({ x: 1 }); const listener = vi.fn(); store.subscribe(listener); store.set("/x", 1); expect(listener).not.toHaveBeenCalled(); expect(store.getSnapshot()).toEqual({ x: 1 }); }); it("update notifies subscribers once", () => { const store = createStateStore({}); const listener = vi.fn(); store.subscribe(listener); store.update({ "/a": 1, "/b": 2 }); expect(listener).toHaveBeenCalledTimes(1); expect(store.get("/a")).toBe(1); expect(store.get("/b")).toBe(2); }); it("update skips notification when no values changed", () => { const store = createStateStore({ a: 1, b: 2 }); const listener = vi.fn(); store.subscribe(listener); store.update({ "/a": 1, "/b": 2 }); expect(listener).not.toHaveBeenCalled(); }); it("unsubscribe stops notifications", () => { const store = createStateStore({}); const listener = vi.fn(); const unsubscribe = store.subscribe(listener); store.set("/x", 1); expect(listener).toHaveBeenCalledTimes(1); unsubscribe(); store.set("/x", 2); expect(listener).toHaveBeenCalledTimes(1); }); it("getSnapshot returns a new reference after mutation", () => { const store = createStateStore({ x: 1 }); const snap1 = store.getSnapshot(); store.set("/x", 2); const snap2 = store.getSnapshot(); expect(snap1).not.toBe(snap2); expect(snap2.x).toBe(2); }); it("getSnapshot returns same reference when set is a no-op", () => { const store = createStateStore({ x: 1 }); const snap1 = store.getSnapshot(); store.set("/x", 1); const snap2 = store.getSnapshot(); expect(snap1).toBe(snap2); }); it("set on nested path does not mutate previous snapshot", () => { const store = createStateStore({ user: { name: "Alice", age: 30 } }); const snap1 = store.getSnapshot(); store.set("/user/name", "Bob"); const snap2 = store.getSnapshot(); expect(snap1.user).toEqual({ name: "Alice", age: 30 }); expect((snap2.user as Record).name).toBe("Bob"); expect(snap1.user).not.toBe(snap2.user); }); it("update on nested paths does not mutate previous snapshot", () => { const store = createStateStore({ user: { name: "Alice" }, meta: { version: 1 }, }); const snap1 = store.getSnapshot(); store.update({ "/user/name": "Bob", "/meta/version": 2 }); const snap2 = store.getSnapshot(); expect((snap1.user as Record).name).toBe("Alice"); expect((snap1.meta as Record).version).toBe(1); expect((snap2.user as Record).name).toBe("Bob"); expect((snap2.meta as Record).version).toBe(2); }); it("set preserves structural sharing for untouched branches", () => { const store = createStateStore({ a: { x: 1 }, b: { y: 2 }, }); const snap1 = store.getSnapshot(); store.set("/a/x", 99); const snap2 = store.getSnapshot(); expect(snap2.b).toBe(snap1.b); expect(snap2.a).not.toBe(snap1.a); }); it("getServerSnapshot returns the same state as getSnapshot", () => { const store = createStateStore({ x: 1 }); expect(store.getServerSnapshot!()).toBe(store.getSnapshot()); store.set("/x", 2); expect(store.getServerSnapshot!()).toBe(store.getSnapshot()); }); }); describe("flattenToPointers", () => { it("flattens top-level keys", () => { expect(flattenToPointers({ a: 1, b: "hello" })).toEqual({ "/a": 1, "/b": "hello", }); }); it("flattens nested plain objects", () => { expect(flattenToPointers({ user: { name: "Alice", age: 30 } })).toEqual({ "/user/name": "Alice", "/user/age": 30, }); }); it("preserves arrays as leaf values", () => { expect(flattenToPointers({ items: [1, 2, 3] })).toEqual({ "/items": [1, 2, 3], }); }); it("preserves null as a leaf value", () => { expect(flattenToPointers({ x: null })).toEqual({ "/x": null }); }); it("handles deeply nested objects", () => { expect(flattenToPointers({ a: { b: { c: 42 } } })).toEqual({ "/a/b/c": 42, }); }); it("returns empty object for empty input", () => { expect(flattenToPointers({})).toEqual({}); }); it("handles mixed nesting", () => { expect( flattenToPointers({ count: 1, user: { name: "Alice" }, tags: ["a", "b"], }), ).toEqual({ "/count": 1, "/user/name": "Alice", "/tags": ["a", "b"], }); }); it("stops recursion on circular references via seen set", () => { const obj: Record = { name: "root" }; obj.self = obj; const result = flattenToPointers(obj); expect(result["/name"]).toBe("root"); expect(result["/self/name"]).toBe("root"); expect(result["/self/self"]).toBe(obj); }); it("caps recursion at depth limit", () => { let current: Record = { leaf: true }; for (let i = 0; i < 25; i++) { current = { nested: current }; } const result = flattenToPointers(current); const keys = Object.keys(result); expect(keys.length).toBe(1); const key = keys[0]!; expect(key.split("/").length).toBeLessThanOrEqual(22); }); }); ================================================ FILE: packages/core/src/state-store.ts ================================================ import { getByPath, parseJsonPointer, type StateModel, type StateStore, } from "./types"; /** * Immutably set a value at a JSON Pointer path using structural sharing. * Only objects along the path are shallow-cloned; untouched branches keep * their original references. */ export function immutableSetByPath( root: StateModel, path: string, value: unknown, ): StateModel { const segments = parseJsonPointer(path); if (segments.length === 0) return root; const result = { ...root }; let current: Record = result; for (let i = 0; i < segments.length - 1; i++) { const seg = segments[i]!; const child = current[seg]; if (Array.isArray(child)) { current[seg] = [...child]; } else if (child !== null && typeof child === "object") { current[seg] = { ...(child as Record) }; } else { const nextSeg = segments[i + 1]; current[seg] = nextSeg !== undefined && /^\d+$/.test(nextSeg) ? [] : {}; } current = current[seg] as Record; } const lastSeg = segments[segments.length - 1]!; if (Array.isArray(current)) { if (lastSeg === "-") { (current as unknown[]).push(value); } else { (current as unknown[])[parseInt(lastSeg, 10)] = value; } } else { current[lastSeg] = value; } return result; } /** * Create a simple in-memory {@link StateStore}. * * This is the default store used by `StateProvider` when no external store is * provided. It mirrors the previous `useState`-based behaviour but is * framework-agnostic so it can also be used in tests or non-React contexts. */ export function createStateStore(initialState: StateModel = {}): StateStore { let state: StateModel = { ...initialState }; const listeners = new Set<() => void>(); function notify() { for (const listener of listeners) { listener(); } } return { get(path: string): unknown { return getByPath(state, path); }, set(path: string, value: unknown): void { if (getByPath(state, path) === value) return; state = immutableSetByPath(state, path, value); notify(); }, update(updates: Record): void { let changed = false; let next = state; for (const [path, value] of Object.entries(updates)) { if (getByPath(next, path) !== value) { next = immutableSetByPath(next, path, value); changed = true; } } if (!changed) return; state = next; notify(); }, getSnapshot(): StateModel { return state; }, getServerSnapshot(): StateModel { return state; }, subscribe(listener: () => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; }, }; } /** * Configuration for {@link createStoreAdapter}. Adapter authors supply these * three callbacks; everything else (get, set, update, no-op detection, * getServerSnapshot) is handled by the returned {@link StateStore}. */ export interface StoreAdapterConfig { /** Return the current state snapshot from the underlying store. */ getSnapshot: () => StateModel; /** Write a new state snapshot to the underlying store. */ setSnapshot: (next: StateModel) => void; /** Subscribe to changes in the underlying store. Return an unsubscribe fn. */ subscribe: (listener: () => void) => () => void; } /** * Build a full {@link StateStore} from a minimal adapter config. * * Handles `get`, `set` (with no-op detection), `update` (batched, with no-op * detection), `getSnapshot`, `getServerSnapshot`, and `subscribe` -- so each * adapter only needs to wire its snapshot source, write API, and subscribe * mechanism. */ export function createStoreAdapter(config: StoreAdapterConfig): StateStore { return { get(path: string): unknown { return getByPath(config.getSnapshot(), path); }, set(path: string, value: unknown): void { const current = config.getSnapshot(); if (getByPath(current, path) === value) return; config.setSnapshot(immutableSetByPath(current, path, value)); }, update(updates: Record): void { let next = config.getSnapshot(); let changed = false; for (const [path, value] of Object.entries(updates)) { if (getByPath(next, path) !== value) { next = immutableSetByPath(next, path, value); changed = true; } } if (!changed) return; config.setSnapshot(next); }, getSnapshot: config.getSnapshot, getServerSnapshot: config.getSnapshot, subscribe: config.subscribe, }; } const MAX_FLATTEN_DEPTH = 20; /** * Recursively flatten a plain object into a `Record` keyed by * JSON Pointer paths. Only leaf values (non-plain-object) appear in the output. * * Includes circular reference protection and a depth cap to prevent stack * overflow on pathological inputs. * * ```ts * flattenToPointers({ user: { name: "Alice" }, count: 1 }) * // => { "/user/name": "Alice", "/count": 1 } * ``` */ export function flattenToPointers( obj: Record, prefix = "", _depth = 0, _seen?: Set, _warned?: { current: boolean }, ): Record { const seen = _seen ?? new Set(); const warned = _warned ?? { current: false }; const result: Record = {}; for (const [key, value] of Object.entries(obj)) { const pointer = `${prefix}/${key}`; if ( _depth < MAX_FLATTEN_DEPTH && value !== null && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype && !seen.has(value) ) { seen.add(value); Object.assign( result, flattenToPointers( value as Record, pointer, _depth + 1, seen, warned, ), ); } else { if ( process.env.NODE_ENV !== "production" && !warned.current && _depth >= MAX_FLATTEN_DEPTH && value !== null && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype && !seen.has(value as object) ) { warned.current = true; console.warn( `flattenToPointers: depth limit (${MAX_FLATTEN_DEPTH}) reached. Nested state beyond this depth will be treated as a leaf value.`, ); } result[pointer] = value; } } return result; } ================================================ FILE: packages/core/src/store-utils.ts ================================================ export { immutableSetByPath, flattenToPointers, createStoreAdapter, } from "./state-store"; export type { StoreAdapterConfig } from "./state-store"; ================================================ FILE: packages/core/src/types.test.ts ================================================ import { describe, it, expect } from "vitest"; import { resolveDynamicValue, getByPath, setByPath, addByPath, removeByPath, applySpecStreamPatch, applySpecPatch, nestedToFlat, compileSpecStream, createSpecStreamCompiler, createMixedStreamParser, createJsonRenderTransform, SPEC_DATA_PART_TYPE, } from "./types"; import type { Spec, SpecStreamLine, StreamChunk } from "./types"; describe("getByPath", () => { it("gets nested values with JSON pointer paths", () => { const data = { user: { name: "John", scores: [10, 20, 30] } }; expect(getByPath(data, "/user/name")).toBe("John"); expect(getByPath(data, "/user/scores/0")).toBe(10); expect(getByPath(data, "/user/scores/1")).toBe(20); }); it("returns root object for empty or root path", () => { const data = { value: 42 }; expect(getByPath(data, "/")).toBe(data); expect(getByPath(data, "")).toBe(data); }); it("returns undefined for missing paths", () => { const data = { user: { name: "John" } }; expect(getByPath(data, "/user/missing")).toBeUndefined(); expect(getByPath(data, "/nonexistent/path")).toBeUndefined(); }); it("handles paths without leading slash", () => { const data = { user: { name: "John" } }; expect(getByPath(data, "user/name")).toBe("John"); }); it("returns undefined when traversing through non-object", () => { const data = { value: "string" }; expect(getByPath(data, "/value/nested")).toBeUndefined(); }); it("handles null values in path", () => { const data = { user: null }; expect(getByPath(data, "/user/name")).toBeUndefined(); }); }); describe("setByPath", () => { it("sets value at existing path", () => { const data: Record = { user: { name: "John" } }; setByPath(data, "/user/name", "Alice"); expect((data.user as Record).name).toBe("Alice"); }); it("creates intermediate objects for deep paths", () => { const data: Record = {}; setByPath(data, "/a/b/c", "deep"); expect( ((data.a as Record).b as Record).c, ).toBe("deep"); }); it("handles paths without leading slash", () => { const data: Record = {}; setByPath(data, "user/name", "John"); expect((data.user as Record).name).toBe("John"); }); it("overwrites existing values", () => { const data: Record = { count: 5 }; setByPath(data, "/count", 10); expect(data.count).toBe(10); }); it("handles array-like paths", () => { const data: Record = { items: {} }; setByPath(data, "/items/0", "first"); expect((data.items as Record)["0"]).toBe("first"); }); }); describe("resolveDynamicValue", () => { it("resolves literal string values", () => { expect(resolveDynamicValue("hello", {})).toBe("hello"); }); it("resolves literal number values", () => { expect(resolveDynamicValue(42, {})).toBe(42); }); it("resolves literal boolean values", () => { expect(resolveDynamicValue(true, {})).toBe(true); expect(resolveDynamicValue(false, {})).toBe(false); }); it("resolves $state references", () => { const data = { user: { name: "Alice" } }; expect(resolveDynamicValue({ $state: "/user/name" }, data)).toBe("Alice"); }); it("returns undefined for missing $state references", () => { const data = { user: { name: "Alice" } }; expect(resolveDynamicValue({ $state: "/missing" }, data)).toBeUndefined(); }); it("returns undefined for null value", () => { expect(resolveDynamicValue(null as unknown as string, {})).toBeUndefined(); }); it("returns undefined for undefined value", () => { expect( resolveDynamicValue(undefined as unknown as string, {}), ).toBeUndefined(); }); }); // ============================================================================= // JSON Pointer (RFC 6901) escaping // ============================================================================= describe("JSON Pointer escaping (RFC 6901)", () => { it("getByPath unescapes ~1 to /", () => { const data = { "a/b": { c: 42 } }; expect(getByPath(data, "/a~1b/c")).toBe(42); }); it("getByPath unescapes ~0 to ~", () => { const data = { "a~b": 99 }; expect(getByPath(data, "/a~0b")).toBe(99); }); it("getByPath handles combined ~0 and ~1 escaping", () => { const data = { "a~/b": "found" }; expect(getByPath(data, "/a~0~1b")).toBe("found"); }); it("setByPath unescapes ~1 to / in keys", () => { const data: Record = {}; setByPath(data, "/a~1b", "value"); expect(data["a/b"]).toBe("value"); }); it("setByPath unescapes ~0 to ~ in keys", () => { const data: Record = {}; setByPath(data, "/a~0b", "value"); expect(data["a~b"]).toBe("value"); }); it("getByPath reads array elements properly", () => { const data = { items: [10, 20, 30] }; expect(getByPath(data, "/items/0")).toBe(10); expect(getByPath(data, "/items/2")).toBe(30); }); }); // ============================================================================= // addByPath (RFC 6902 "add" semantics) // ============================================================================= describe("addByPath", () => { it("adds a property to an object", () => { const data: Record = { a: 1 }; addByPath(data, "/b", 2); expect(data.b).toBe(2); }); it("replaces an existing object property (add semantics)", () => { const data: Record = { a: 1 }; addByPath(data, "/a", 99); expect(data.a).toBe(99); }); it("inserts into an array at a specific index", () => { const data: Record = { arr: [1, 2, 3] }; addByPath(data, "/arr/1", 99); expect(data.arr).toEqual([1, 99, 2, 3]); }); it("appends to an array with - (end-of-array)", () => { const data: Record = { arr: [1, 2] }; addByPath(data, "/arr/-", 3); expect(data.arr).toEqual([1, 2, 3]); }); it("inserts at index 0 of an array", () => { const data: Record = { arr: ["b", "c"] }; addByPath(data, "/arr/0", "a"); expect(data.arr).toEqual(["a", "b", "c"]); }); it("creates intermediate objects", () => { const data: Record = {}; addByPath(data, "/x/y/z", "deep"); expect( ((data.x as Record).y as Record).z, ).toBe("deep"); }); }); // ============================================================================= // removeByPath (RFC 6902 "remove" semantics) // ============================================================================= describe("removeByPath", () => { it("deletes an object property", () => { const data: Record = { a: 1, b: 2 }; removeByPath(data, "/a"); expect(data).toEqual({ b: 2 }); expect("a" in data).toBe(false); }); it("removes an element from an array by index", () => { const data: Record = { arr: [1, 2, 3] }; removeByPath(data, "/arr/1"); expect(data.arr).toEqual([1, 3]); }); it("removes nested properties", () => { const data: Record = { user: { name: "John", age: 30 } }; removeByPath(data, "/user/age"); expect(data.user).toEqual({ name: "John" }); }); it("is a no-op for non-existent paths", () => { const data: Record = { a: 1 }; removeByPath(data, "/b/c/d"); expect(data).toEqual({ a: 1 }); }); }); // ============================================================================= // applySpecStreamPatch - RFC 6902 operations // ============================================================================= describe("applySpecStreamPatch", () => { describe("add operation", () => { it("adds a new object property", () => { const obj: Record = {}; applySpecStreamPatch(obj, { op: "add", path: "/name", value: "Alice" }); expect(obj.name).toBe("Alice"); }); it("adds nested properties creating intermediates", () => { const obj: Record = {}; applySpecStreamPatch(obj, { op: "add", path: "/user/name", value: "Bob", }); expect((obj.user as Record).name).toBe("Bob"); }); it("inserts into array at index", () => { const obj: Record = { items: [1, 3] }; applySpecStreamPatch(obj, { op: "add", path: "/items/1", value: 2 }); expect(obj.items).toEqual([1, 2, 3]); }); it("appends to array with -", () => { const obj: Record = { items: [1, 2] }; applySpecStreamPatch(obj, { op: "add", path: "/items/-", value: 3 }); expect(obj.items).toEqual([1, 2, 3]); }); }); describe("remove operation", () => { it("removes an object property", () => { const obj: Record = { name: "Alice", age: 30 }; applySpecStreamPatch(obj, { op: "remove", path: "/age" }); expect(obj).toEqual({ name: "Alice" }); expect("age" in obj).toBe(false); }); it("removes from array by index", () => { const obj: Record = { items: ["a", "b", "c"] }; applySpecStreamPatch(obj, { op: "remove", path: "/items/1" }); expect(obj.items).toEqual(["a", "c"]); }); }); describe("replace operation", () => { it("replaces an existing value", () => { const obj: Record = { name: "Alice" }; applySpecStreamPatch(obj, { op: "replace", path: "/name", value: "Bob", }); expect(obj.name).toBe("Bob"); }); it("replaces nested values", () => { const obj: Record = { user: { name: "Alice" } }; applySpecStreamPatch(obj, { op: "replace", path: "/user/name", value: "Bob", }); expect((obj.user as Record).name).toBe("Bob"); }); }); describe("move operation", () => { it("moves a value from one path to another", () => { const obj: Record = { a: 1, b: 2 }; applySpecStreamPatch(obj, { op: "move", path: "/c", from: "/a" }); expect(obj).toEqual({ b: 2, c: 1 }); expect("a" in obj).toBe(false); }); it("moves nested values", () => { const obj: Record = { source: { val: 42 }, target: {}, }; applySpecStreamPatch(obj, { op: "move", path: "/target/val", from: "/source/val", }); expect((obj.target as Record).val).toBe(42); expect("val" in (obj.source as Record)).toBe(false); }); it("is a no-op if from is missing", () => { const obj: Record = { a: 1 }; applySpecStreamPatch(obj, { op: "move", path: "/b" }); expect(obj).toEqual({ a: 1 }); }); }); describe("copy operation", () => { it("copies a value from one path to another", () => { const obj: Record = { a: 1 }; applySpecStreamPatch(obj, { op: "copy", path: "/b", from: "/a" }); expect(obj).toEqual({ a: 1, b: 1 }); }); it("copies complex values", () => { const obj: Record = { source: { nested: { value: 42 } }, }; applySpecStreamPatch(obj, { op: "copy", path: "/dest", from: "/source/nested", }); expect(obj.dest).toEqual({ value: 42 }); // Source should still exist expect((obj.source as Record).nested).toEqual({ value: 42, }); }); it("is a no-op if from is missing", () => { const obj: Record = { a: 1 }; applySpecStreamPatch(obj, { op: "copy", path: "/b" }); expect(obj).toEqual({ a: 1 }); }); }); describe("test operation", () => { it("succeeds when values match", () => { const obj: Record = { name: "Alice" }; expect(() => applySpecStreamPatch(obj, { op: "test", path: "/name", value: "Alice", }), ).not.toThrow(); }); it("succeeds for matching objects", () => { const obj: Record = { user: { name: "Alice", age: 30 } }; expect(() => applySpecStreamPatch(obj, { op: "test", path: "/user", value: { name: "Alice", age: 30 }, }), ).not.toThrow(); }); it("succeeds for matching arrays", () => { const obj: Record = { items: [1, 2, 3] }; expect(() => applySpecStreamPatch(obj, { op: "test", path: "/items", value: [1, 2, 3], }), ).not.toThrow(); }); it("throws when values do not match", () => { const obj: Record = { name: "Alice" }; expect(() => applySpecStreamPatch(obj, { op: "test", path: "/name", value: "Bob", }), ).toThrow('Test operation failed: value at "/name" does not match'); }); it("throws when path does not exist", () => { const obj: Record = {}; expect(() => applySpecStreamPatch(obj, { op: "test", path: "/missing", value: "anything", }), ).toThrow(); }); }); }); // ============================================================================= // compileSpecStream // ============================================================================= describe("compileSpecStream", () => { it("compiles a series of add patches", () => { const stream = `{"op":"add","path":"/name","value":"Alice"} {"op":"add","path":"/age","value":30}`; const result = compileSpecStream(stream); expect(result).toEqual({ name: "Alice", age: 30 }); }); it("handles remove operations", () => { const stream = `{"op":"add","path":"/a","value":1} {"op":"add","path":"/b","value":2} {"op":"remove","path":"/a"}`; const result = compileSpecStream(stream); expect(result).toEqual({ b: 2 }); expect("a" in result).toBe(false); }); it("handles replace operations", () => { const stream = `{"op":"add","path":"/name","value":"Alice"} {"op":"replace","path":"/name","value":"Bob"}`; const result = compileSpecStream(stream); expect(result).toEqual({ name: "Bob" }); }); it("handles move operations", () => { const stream = `{"op":"add","path":"/old","value":"data"} {"op":"move","path":"/new","from":"/old"}`; const result = compileSpecStream(stream); expect(result).toEqual({ new: "data" }); }); it("handles copy operations", () => { const stream = `{"op":"add","path":"/original","value":"data"} {"op":"copy","path":"/duplicate","from":"/original"}`; const result = compileSpecStream(stream); expect(result).toEqual({ original: "data", duplicate: "data" }); }); it("skips empty lines", () => { const stream = `{"op":"add","path":"/a","value":1} {"op":"add","path":"/b","value":2}`; const result = compileSpecStream(stream); expect(result).toEqual({ a: 1, b: 2 }); }); it("uses initial value", () => { const stream = `{"op":"add","path":"/b","value":2}`; const result = compileSpecStream(stream, { a: 1 }); expect(result).toEqual({ a: 1, b: 2 }); }); }); // ============================================================================= // createSpecStreamCompiler // ============================================================================= describe("createSpecStreamCompiler", () => { it("processes chunks incrementally", () => { const compiler = createSpecStreamCompiler(); const { result, newPatches } = compiler.push( '{"op":"add","path":"/name","value":"Alice"}\n', ); expect(newPatches).toHaveLength(1); expect(result).toEqual({ name: "Alice" }); }); it("handles partial lines across chunks", () => { const compiler = createSpecStreamCompiler(); // First chunk is incomplete const r1 = compiler.push('{"op":"add","path":"/na'); expect(r1.newPatches).toHaveLength(0); // Complete the line const r2 = compiler.push('me","value":"Alice"}\n'); expect(r2.newPatches).toHaveLength(1); expect(r2.result).toEqual({ name: "Alice" }); }); it("processes remaining buffer on getResult", () => { const compiler = createSpecStreamCompiler(); compiler.push('{"op":"add","path":"/name","value":"Alice"}'); // No newline, so not processed yet const result = compiler.getResult(); expect(result).toEqual({ name: "Alice" }); }); it("resets to clean state", () => { const compiler = createSpecStreamCompiler(); compiler.push('{"op":"add","path":"/name","value":"Alice"}\n'); compiler.reset(); const result = compiler.getResult(); expect(result).toEqual({}); }); it("tracks all applied patches", () => { const compiler = createSpecStreamCompiler(); compiler.push('{"op":"add","path":"/a","value":1}\n'); compiler.push('{"op":"add","path":"/b","value":2}\n'); const patches = compiler.getPatches(); expect(patches).toHaveLength(2); expect(patches[0]!.op).toBe("add"); expect(patches[1]!.op).toBe("add"); }); it("handles all RFC 6902 operations", () => { const compiler = createSpecStreamCompiler(); compiler.push('{"op":"add","path":"/x","value":1}\n'); compiler.push('{"op":"add","path":"/y","value":2}\n'); compiler.push('{"op":"move","path":"/z","from":"/x"}\n'); compiler.push('{"op":"copy","path":"/w","from":"/y"}\n'); compiler.push('{"op":"remove","path":"/y"}\n'); const result = compiler.getResult(); expect(result).toEqual({ z: 1, w: 2 }); }); }); // ============================================================================= // applySpecPatch // ============================================================================= describe("applySpecPatch", () => { it("sets the root element", () => { const spec: Spec = { root: "", elements: {} }; applySpecPatch(spec, { op: "add", path: "/root", value: "main" }); expect(spec.root).toBe("main"); }); it("adds an element to the elements map", () => { const spec: Spec = { root: "main", elements: {} }; applySpecPatch(spec, { op: "add", path: "/elements/main", value: { type: "Card", props: { title: "Hello" }, children: [] }, }); expect(spec.elements.main).toEqual({ type: "Card", props: { title: "Hello" }, children: [], }); }); it("replaces an existing element", () => { const spec: Spec = { root: "main", elements: { main: { type: "Card", props: { title: "Old" }, children: [] }, }, }; applySpecPatch(spec, { op: "replace", path: "/elements/main/props/title", value: "New", }); expect(spec.elements.main!.props.title).toBe("New"); }); it("removes an element", () => { const spec: Spec = { root: "main", elements: { main: { type: "Card", props: {}, children: [] }, child: { type: "Text", props: {}, children: [] }, }, }; applySpecPatch(spec, { op: "remove", path: "/elements/child" }); expect(spec.elements.child).toBeUndefined(); expect(spec.elements.main).toBeDefined(); }); it("adds state data", () => { const spec: Spec = { root: "main", elements: {} }; applySpecPatch(spec, { op: "add", path: "/state", value: { count: 0 }, }); expect(spec.state).toEqual({ count: 0 }); }); it("returns the mutated spec", () => { const spec: Spec = { root: "", elements: {} }; const result = applySpecPatch(spec, { op: "add", path: "/root", value: "main", }); expect(result).toBe(spec); }); }); // ============================================================================= // createMixedStreamParser // ============================================================================= describe("createMixedStreamParser", () => { it("classifies JSONL lines as patches", () => { const patches: SpecStreamLine[] = []; const texts: string[] = []; const parser = createMixedStreamParser({ onPatch: (p) => patches.push(p), onText: (t) => texts.push(t), }); parser.push('{"op":"add","path":"/root","value":"main"}\n'); expect(patches).toHaveLength(1); expect(patches[0]!.op).toBe("add"); expect(patches[0]!.path).toBe("/root"); expect(texts).toHaveLength(0); }); it("classifies non-JSONL lines as text", () => { const patches: SpecStreamLine[] = []; const texts: string[] = []; const parser = createMixedStreamParser({ onPatch: (p) => patches.push(p), onText: (t) => texts.push(t), }); parser.push("Hello, here is your UI:\n"); expect(texts).toHaveLength(1); expect(texts[0]).toBe("Hello, here is your UI:"); expect(patches).toHaveLength(0); }); it("handles mixed text and JSONL", () => { const patches: SpecStreamLine[] = []; const texts: string[] = []; const parser = createMixedStreamParser({ onPatch: (p) => patches.push(p), onText: (t) => texts.push(t), }); parser.push( 'Here is the dashboard:\n{"op":"add","path":"/root","value":"dash"}\n', ); expect(texts).toHaveLength(1); expect(texts[0]).toBe("Here is the dashboard:"); expect(patches).toHaveLength(1); expect(patches[0]!.path).toBe("/root"); }); it("buffers partial lines across chunks", () => { const patches: SpecStreamLine[] = []; const texts: string[] = []; const parser = createMixedStreamParser({ onPatch: (p) => patches.push(p), onText: (t) => texts.push(t), }); parser.push('{"op":"add","path":"/ro'); expect(patches).toHaveLength(0); parser.push('ot","value":"main"}\n'); expect(patches).toHaveLength(1); expect(patches[0]!.path).toBe("/root"); }); it("flushes remaining buffer on flush()", () => { const patches: SpecStreamLine[] = []; const texts: string[] = []; const parser = createMixedStreamParser({ onPatch: (p) => patches.push(p), onText: (t) => texts.push(t), }); // No trailing newline — stuck in buffer parser.push('{"op":"add","path":"/root","value":"main"}'); expect(patches).toHaveLength(0); parser.flush(); expect(patches).toHaveLength(1); }); it("flushes text buffer on flush()", () => { const patches: SpecStreamLine[] = []; const texts: string[] = []; const parser = createMixedStreamParser({ onPatch: (p) => patches.push(p), onText: (t) => texts.push(t), }); parser.push("Some trailing text"); expect(texts).toHaveLength(0); parser.flush(); expect(texts).toHaveLength(1); expect(texts[0]).toBe("Some trailing text"); }); it("skips empty lines", () => { const patches: SpecStreamLine[] = []; const texts: string[] = []; const parser = createMixedStreamParser({ onPatch: (p) => patches.push(p), onText: (t) => texts.push(t), }); parser.push("\n\n\n"); expect(patches).toHaveLength(0); expect(texts).toHaveLength(0); }); }); // ============================================================================= // nestedToFlat // ============================================================================= describe("nestedToFlat", () => { it("converts a single node with no children", () => { const spec = nestedToFlat({ type: "Text", props: { content: "Hello" }, }); expect(spec.root).toBe("el-0"); expect(spec.elements["el-0"]).toEqual({ type: "Text", props: { content: "Hello" }, children: [], }); }); it("converts a tree with children", () => { const spec = nestedToFlat({ type: "Card", props: { title: "Hello" }, children: [ { type: "Text", props: { content: "World" }, children: [] }, { type: "Button", props: { label: "Click" } }, ], }); expect(spec.root).toBe("el-0"); expect(Object.keys(spec.elements)).toHaveLength(3); expect(spec.elements["el-0"]!.type).toBe("Card"); expect(spec.elements["el-0"]!.children).toEqual(["el-1", "el-2"]); expect(spec.elements["el-1"]!.type).toBe("Text"); expect(spec.elements["el-1"]!.children).toEqual([]); expect(spec.elements["el-2"]!.type).toBe("Button"); expect(spec.elements["el-2"]!.children).toEqual([]); }); it("hoists state from root node", () => { const spec = nestedToFlat({ type: "Card", props: { title: "Hello" }, children: [], state: { count: 0, items: [] }, }); expect(spec.state).toEqual({ count: 0, items: [] }); // state should not appear as a field on the element expect( (spec.elements["el-0"] as Record).state, ).toBeUndefined(); }); it("preserves extra fields like visible and on", () => { const spec = nestedToFlat({ type: "Panel", props: {}, visible: { $state: "/showPanel" }, on: { press: { action: "setState", params: { statePath: "/x", value: 1 } }, }, children: [], }); const el = spec.elements["el-0"] as Record; expect(el.visible).toEqual({ $state: "/showPanel" }); expect(el.on).toEqual({ press: { action: "setState", params: { statePath: "/x", value: 1 } }, }); }); it("handles deeply nested trees", () => { const spec = nestedToFlat({ type: "A", props: {}, children: [ { type: "B", props: {}, children: [ { type: "C", props: {}, children: [{ type: "D", props: {} }], }, ], }, ], }); expect(Object.keys(spec.elements)).toHaveLength(4); expect(spec.elements["el-0"]!.children).toEqual(["el-1"]); expect(spec.elements["el-1"]!.children).toEqual(["el-2"]); expect(spec.elements["el-2"]!.children).toEqual(["el-3"]); expect(spec.elements["el-3"]!.children).toEqual([]); }); it("returns empty elements for empty children array", () => { const spec = nestedToFlat({ type: "Empty", props: {}, children: [], }); expect(Object.keys(spec.elements)).toHaveLength(1); expect(spec.elements["el-0"]!.children).toEqual([]); }); it("does not hoist state from non-root nodes", () => { const spec = nestedToFlat({ type: "Root", props: {}, children: [{ type: "Child", props: {}, state: { shouldNotHoist: true } }], }); // Only root state hoists to spec.state expect(spec.state).toBeUndefined(); // The child's state is not a standard field, so it should not leak expect( (spec.elements["el-1"] as Record).state, ).toBeUndefined(); }); }); // ============================================================================= // createJsonRenderTransform // ============================================================================= describe("createJsonRenderTransform", () => { /** Helper: push text-delta chunks through the transform and collect output */ async function transformText(text: string): Promise { const transform = createJsonRenderTransform(); const writer = transform.writable.getWriter(); const reader = transform.readable.getReader(); const chunks: StreamChunk[] = []; // Read and write concurrently to avoid backpressure deadlock const readAll = (async () => { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } })(); await writer.write({ type: "text-start", id: "t1" }); await writer.write({ type: "text-delta", id: "t1", delta: text }); await writer.write({ type: "text-end", id: "t1" }); await writer.close(); await readAll; return chunks; } it("passes prose text through as text-delta", async () => { const chunks = await transformText("Hello world\n"); const textChunks = chunks.filter((c) => c.type === "text-delta"); const text = textChunks.map((c) => (c as { delta: string }).delta).join(""); expect(text).toContain("Hello world"); }); it("classifies valid JSONL patches as data-spec (heuristic mode)", async () => { const patch = '{"op":"add","path":"/root","value":"main"}\n'; const chunks = await transformText(patch); const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE); expect(specChunks.length).toBe(1); expect((specChunks[0] as { data: { type: string } }).data.type).toBe( "patch", ); }); it("lines starting with { that are NOT patches are flushed as text", async () => { const line = '{"not":"a patch"}\n'; const chunks = await transformText(line); const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE); const textChunks = chunks.filter((c) => c.type === "text-delta"); expect(specChunks.length).toBe(0); const text = textChunks.map((c) => (c as { delta: string }).delta).join(""); expect(text).toContain('{"not":"a patch"}'); }); it("parses content inside ```spec fence as patches", async () => { const input = [ "Here is some UI:\n", "```spec\n", '{"op":"add","path":"/root","value":"main"}\n', '{"op":"add","path":"/elements/main","value":{"type":"Card","props":{},"children":[]}}\n', "```\n", "Done!\n", ].join(""); const chunks = await transformText(input); const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE); expect(specChunks.length).toBe(2); // Prose before and after should come through const textChunks = chunks.filter((c) => c.type === "text-delta"); const text = textChunks.map((c) => (c as { delta: string }).delta).join(""); expect(text).toContain("Here is some UI:"); expect(text).toContain("Done!"); // Fence delimiters should NOT appear in text expect(text).not.toContain("```spec"); }); it("handles mixed text + heuristic patches in single stream", async () => { const input = [ "Some text\n", '{"op":"add","path":"/root","value":"r"}\n', "More text\n", ].join(""); const chunks = await transformText(input); const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE); expect(specChunks.length).toBe(1); const textChunks = chunks.filter((c) => c.type === "text-delta"); const text = textChunks.map((c) => (c as { delta: string }).delta).join(""); expect(text).toContain("Some text"); expect(text).toContain("More text"); }); it("non-text chunks pass through unchanged", async () => { const transform = createJsonRenderTransform(); const writer = transform.writable.getWriter(); const reader = transform.readable.getReader(); const toolChunk = { type: "tool-call", toolCallId: "abc", toolName: "test", }; const readPromise = reader.read(); await writer.write(toolChunk as StreamChunk); await writer.close(); const { value } = await readPromise; expect(value).toEqual(toolChunk); }); it("flush behavior at end of stream", async () => { const transform = createJsonRenderTransform(); const writer = transform.writable.getWriter(); const reader = transform.readable.getReader(); const chunks: StreamChunk[] = []; const readAll = (async () => { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } })(); // Write a text delta with no trailing newline await writer.write({ type: "text-start", id: "t1" }); await writer.write({ type: "text-delta", id: "t1", delta: '{"op":"add","path":"/root","value":"main"}', }); await writer.write({ type: "text-end", id: "t1" }); await writer.close(); await readAll; // The buffered patch should be flushed on text-end const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE); expect(specChunks.length).toBe(1); }); // =========================================================================== // Text block splitting around spec data // =========================================================================== it("splits text blocks around spec data (text-start/text-end pairs)", async () => { const input = [ "Some text\n", '{"op":"add","path":"/root","value":"r"}\n', "More text\n", ].join(""); const chunks = await transformText(input); const textStarts = chunks.filter((c) => c.type === "text-start"); const textEnds = chunks.filter((c) => c.type === "text-end"); // There should be two text blocks: one before the patch and one after expect(textStarts.length).toBe(2); expect(textEnds.length).toBe(2); // Spec data should appear between the two text blocks const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE); expect(specChunks.length).toBe(1); // Find the indices of the first text-end and the spec chunk const firstTextEndIdx = chunks.findIndex((c) => c.type === "text-end"); const specIdx = chunks.findIndex((c) => c.type === SPEC_DATA_PART_TYPE); const secondTextStartIdx = chunks.findIndex( (c, i) => i > specIdx && c.type === "text-start", ); expect(firstTextEndIdx).toBeLessThan(specIdx); expect(specIdx).toBeLessThan(secondTextStartIdx); }); it("flush closes an open text block when stream ends without text-end", async () => { const transform = createJsonRenderTransform(); const writer = transform.writable.getWriter(); const reader = transform.readable.getReader(); const chunks: StreamChunk[] = []; const readAll = (async () => { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } })(); // Write text-start + text-delta, then close WITHOUT text-end await writer.write({ type: "text-start", id: "t1" }); await writer.write({ type: "text-delta", id: "t1", delta: "Hello world\n", }); await writer.close(); await readAll; // The transform's flush should have emitted a text-end to close the block const textEnds = chunks.filter((c) => c.type === "text-end"); expect(textEnds.length).toBe(1); // Text content should still be present const textChunks = chunks.filter((c) => c.type === "text-delta"); const text = textChunks.map((c) => (c as { delta: string }).delta).join(""); expect(text).toContain("Hello world"); }); it("consecutive patches do not produce empty text blocks", async () => { const input = [ '{"op":"add","path":"/root","value":"r"}\n', '{"op":"add","path":"/elements/r","value":{"type":"Card","props":{},"children":[]}}\n', ].join(""); const chunks = await transformText(input); const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE); expect(specChunks.length).toBe(2); // There should be no text-start/text-end pairs between the two spec chunks // (the initial text-start from the upstream is forwarded, but no new empty ones) const textDeltas = chunks.filter((c) => c.type === "text-delta"); const textContent = textDeltas .map((c) => (c as { delta: string }).delta) .join("") .trim(); // No meaningful text content between the patches expect(textContent).toBe(""); // Count text blocks: there should be at most 1 (the initial upstream one), // not extra empty ones inserted between patches const textStarts = chunks.filter((c) => c.type === "text-start"); const textEnds = chunks.filter((c) => c.type === "text-end"); expect(textStarts.length).toBeLessThanOrEqual(1); expect(textEnds.length).toBeLessThanOrEqual(1); }); }); // ============================================================================= // createMixedStreamParser - fence mode // ============================================================================= describe("createMixedStreamParser - fence mode", () => { it("parses patches inside ```spec fence", () => { const patches: SpecStreamLine[] = []; const texts: string[] = []; const parser = createMixedStreamParser({ onPatch: (p) => patches.push(p), onText: (t) => texts.push(t), }); parser.push("Hello\n"); parser.push("```spec\n"); parser.push('{"op":"add","path":"/root","value":"main"}\n'); parser.push("```\n"); parser.push("Goodbye\n"); parser.flush(); expect(patches.length).toBe(1); expect(patches[0].op).toBe("add"); expect(texts).toContain("Hello"); expect(texts).toContain("Goodbye"); }); it("fence delimiters are not emitted as text or patches", () => { const patches: SpecStreamLine[] = []; const texts: string[] = []; const parser = createMixedStreamParser({ onPatch: (p) => patches.push(p), onText: (t) => texts.push(t), }); parser.push("```spec\n"); parser.push('{"op":"add","path":"/root","value":"r"}\n'); parser.push("```\n"); parser.flush(); expect(patches.length).toBe(1); expect(texts.length).toBe(0); }); }); ================================================ FILE: packages/core/src/types.ts ================================================ import { z } from "zod"; import type { ActionBinding } from "./actions"; /** * Dynamic value - can be a literal or a `{ $state }` reference to the state model. * * Used in action params and validation args where values can either be * hardcoded or resolved from state at runtime. */ export type DynamicValue = T | { $state: string }; /** * Dynamic string value */ export type DynamicString = DynamicValue; /** * Dynamic number value */ export type DynamicNumber = DynamicValue; /** * Dynamic boolean value */ export type DynamicBoolean = DynamicValue; /** * Zod schema for dynamic values */ export const DynamicValueSchema = z.union([ z.string(), z.number(), z.boolean(), z.null(), z.object({ $state: z.string() }), ]); export const DynamicStringSchema = z.union([ z.string(), z.object({ $state: z.string() }), ]); export const DynamicNumberSchema = z.union([ z.number(), z.object({ $state: z.string() }), ]); export const DynamicBooleanSchema = z.union([ z.boolean(), z.object({ $state: z.string() }), ]); /** * Base UI element structure for v2 */ export interface UIElement< T extends string = string, P = Record, > { /** Component type from the catalog */ type: T; /** Component props */ props: P; /** Child element keys (flat structure) */ children?: string[]; /** Visibility condition */ visible?: VisibilityCondition; /** Event bindings — maps event names to action bindings */ on?: Record; /** Repeat children once per item in a state array */ repeat?: { statePath: string; key?: string }; /** * State watchers — maps JSON Pointer state paths to action bindings. * When the value at a watched path changes, the bound actions fire. * Useful for cascading dependencies (e.g. country → city option loading). */ watch?: Record; } /** * Element with key and parentKey for use with flatToTree. * When elements are in an array (not a keyed map), key and parentKey * are needed to establish identity and parent-child relationships. */ export interface FlatElement< T extends string = string, P = Record, > extends UIElement { /** Unique key identifying this element */ key: string; /** Parent element key (null for root) */ parentKey?: string | null; } /** * Shared comparison operators for visibility conditions. * * Use at most ONE comparison operator per condition. If multiple are * provided, only the first matching one is evaluated (precedence: * eq > neq > gt > gte > lt > lte). With no operator, truthiness is checked. * * `not` inverts the final result of whichever operator (or truthiness * check) is used. */ type ComparisonOperators = { eq?: unknown; neq?: unknown; gt?: number | { $state: string }; gte?: number | { $state: string }; lt?: number | { $state: string }; lte?: number | { $state: string }; not?: true; }; /** * A single state-based condition. * Resolves `$state` to a value from the state model, then applies the operator. * Without an operator, checks truthiness. * * When `not` is `true`, the result of the entire condition is inverted. * For example `{ $state: "/count", gt: 5, not: true }` means "NOT greater than 5". */ export type StateCondition = { $state: string } & ComparisonOperators; /** * A condition that resolves `$item` to a field on the current repeat item. * Only meaningful inside a `repeat` scope. * * Use `""` to reference the whole item, or `"field"` for a specific field. */ export type ItemCondition = { $item: string } & ComparisonOperators; /** * A condition that resolves `$index` to the current repeat array index. * Only meaningful inside a `repeat` scope. */ export type IndexCondition = { $index: true } & ComparisonOperators; /** A single visibility condition (state, item, or index). */ export type SingleCondition = StateCondition | ItemCondition | IndexCondition; /** * AND wrapper — all child conditions must be true. * This is the explicit form of the implicit array AND (`SingleCondition[]`). * Unlike the implicit form, `$and` supports nested `$or` and `$and` conditions. */ export type AndCondition = { $and: VisibilityCondition[] }; /** * OR wrapper — at least one child condition must be true. */ export type OrCondition = { $or: VisibilityCondition[] }; /** * Visibility condition types. * - `boolean` — always/never * - `SingleCondition` — single condition (`$state`, `$item`, or `$index`) * - `SingleCondition[]` — implicit AND (all must be true) * - `AndCondition` — `{ $and: [...] }`, explicit AND (all must be true) * - `OrCondition` — `{ $or: [...] }`, at least one must be true */ export type VisibilityCondition = | boolean | SingleCondition | SingleCondition[] | AndCondition | OrCondition; /** * Flat UI tree structure (optimized for LLM generation) */ export interface Spec { /** Root element key */ root: string; /** Flat map of elements by key */ elements: Record; /** Optional initial state to seed the state model. * Components using statePath will read from / write to this state. */ state?: Record; } /** * State model type */ export type StateModel = Record; /** * An abstract store that owns state and notifies subscribers on change. * * Consumers can supply their own implementation (backed by Redux, Zustand, * XState, etc.) or use the built-in {@link createStateStore} for a simple * in-memory store. */ export interface StateStore { /** Read a value by JSON Pointer path. */ get: (path: string) => unknown; /** * Write a value by JSON Pointer path and notify subscribers. * Equality is checked by reference (`===`), not deep comparison. * Callers must pass a new object/array reference for changes to be detected. */ set: (path: string, value: unknown) => void; /** * Write multiple values at once and notify subscribers (single notification). * Each value is compared by reference (`===`); only paths whose value * actually changed are applied. */ update: (updates: Record) => void; /** Return the full state object (used by `useSyncExternalStore`). */ getSnapshot: () => StateModel; /** Optional server snapshot for SSR (passed to `useSyncExternalStore`). Falls back to `getSnapshot` when omitted. */ getServerSnapshot?: () => StateModel; /** Register a listener that is called on every state change. Returns an unsubscribe function. */ subscribe: (listener: () => void) => () => void; } /** * Component schema definition using Zod */ export type ComponentSchema = z.ZodType>; /** * Validation mode for catalog validation */ export type ValidationMode = "strict" | "warn" | "ignore"; /** * JSON patch operation types (RFC 6902) */ export type PatchOp = "add" | "remove" | "replace" | "move" | "copy" | "test"; /** * JSON patch operation (RFC 6902) */ export interface JsonPatch { op: PatchOp; path: string; /** Required for add, replace, test */ value?: unknown; /** Required for move, copy (source location) */ from?: string; } /** * Resolve a dynamic value against a state model */ export function resolveDynamicValue( value: DynamicValue, stateModel: StateModel, ): T | undefined { if (value === null || value === undefined) { return undefined; } if (typeof value === "object" && "$state" in value) { return getByPath(stateModel, (value as { $state: string }).$state) as | T | undefined; } return value as T; } /** * Unescape a JSON Pointer token per RFC 6901 Section 4. * ~1 is decoded to / and ~0 is decoded to ~ (order matters). */ function unescapeJsonPointer(token: string): string { return token.replace(/~1/g, "/").replace(/~0/g, "~"); } /** * Parse a JSON Pointer path into unescaped segments. */ export function parseJsonPointer(path: string): string[] { const raw = path.startsWith("/") ? path.slice(1).split("/") : path.split("/"); return raw.map(unescapeJsonPointer); } /** * Get a value from an object by JSON Pointer path (RFC 6901) */ export function getByPath(obj: unknown, path: string): unknown { if (!path || path === "/") { return obj; } const segments = parseJsonPointer(path); let current: unknown = obj; for (const segment of segments) { if (current === null || current === undefined) { return undefined; } if (Array.isArray(current)) { const index = parseInt(segment, 10); current = current[index]; } else if (typeof current === "object") { current = (current as Record)[segment]; } else { return undefined; } } return current; } /** * Check if a string is a numeric index */ function isNumericIndex(str: string): boolean { return /^\d+$/.test(str); } /** * Set a value in an object by JSON Pointer path (RFC 6901). * Automatically creates arrays when the path segment is a numeric index. */ export function setByPath( obj: Record, path: string, value: unknown, ): void { const segments = parseJsonPointer(path); if (segments.length === 0) return; let current: Record | unknown[] = obj; for (let i = 0; i < segments.length - 1; i++) { const segment = segments[i]!; const nextSegment = segments[i + 1]; const nextIsNumeric = nextSegment !== undefined && (isNumericIndex(nextSegment) || nextSegment === "-"); if (Array.isArray(current)) { const index = parseInt(segment, 10); if (current[index] === undefined || typeof current[index] !== "object") { current[index] = nextIsNumeric ? [] : {}; } current = current[index] as Record | unknown[]; } else { if (!(segment in current) || typeof current[segment] !== "object") { current[segment] = nextIsNumeric ? [] : {}; } current = current[segment] as Record | unknown[]; } } const lastSegment = segments[segments.length - 1]!; if (Array.isArray(current)) { if (lastSegment === "-") { current.push(value); } else { const index = parseInt(lastSegment, 10); current[index] = value; } } else { current[lastSegment] = value; } } /** * Add a value per RFC 6902 "add" semantics. * For objects: create-or-replace the member. * For arrays: insert before the given index, or append if "-". */ export function addByPath( obj: Record, path: string, value: unknown, ): void { const segments = parseJsonPointer(path); if (segments.length === 0) return; let current: Record | unknown[] = obj; for (let i = 0; i < segments.length - 1; i++) { const segment = segments[i]!; const nextSegment = segments[i + 1]; const nextIsNumeric = nextSegment !== undefined && (isNumericIndex(nextSegment) || nextSegment === "-"); if (Array.isArray(current)) { const index = parseInt(segment, 10); if (current[index] === undefined || typeof current[index] !== "object") { current[index] = nextIsNumeric ? [] : {}; } current = current[index] as Record | unknown[]; } else { if (!(segment in current) || typeof current[segment] !== "object") { current[segment] = nextIsNumeric ? [] : {}; } current = current[segment] as Record | unknown[]; } } const lastSegment = segments[segments.length - 1]!; if (Array.isArray(current)) { if (lastSegment === "-") { current.push(value); } else { const index = parseInt(lastSegment, 10); current.splice(index, 0, value); } } else { current[lastSegment] = value; } } /** * Remove a value per RFC 6902 "remove" semantics. * For objects: delete the property. * For arrays: splice out the element at the given index. */ export function removeByPath(obj: Record, path: string): void { const segments = parseJsonPointer(path); if (segments.length === 0) return; let current: Record | unknown[] = obj; for (let i = 0; i < segments.length - 1; i++) { const segment = segments[i]!; if (Array.isArray(current)) { const index = parseInt(segment, 10); if (current[index] === undefined || typeof current[index] !== "object") { return; // path does not exist } current = current[index] as Record | unknown[]; } else { if (!(segment in current) || typeof current[segment] !== "object") { return; // path does not exist } current = current[segment] as Record | unknown[]; } } const lastSegment = segments[segments.length - 1]!; if (Array.isArray(current)) { const index = parseInt(lastSegment, 10); if (index >= 0 && index < current.length) { current.splice(index, 1); } } else { delete current[lastSegment]; } } /** * Deep equality check for RFC 6902 "test" operation. */ function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (a === null || b === null) return false; if (typeof a !== typeof b) return false; if (typeof a !== "object") return false; if (Array.isArray(a)) { if (!Array.isArray(b)) return false; if (a.length !== b.length) return false; return a.every((item, i) => deepEqual(item, b[i])); } const aObj = a as Record; const bObj = b as Record; const aKeys = Object.keys(aObj); const bKeys = Object.keys(bObj); if (aKeys.length !== bKeys.length) return false; return aKeys.every((key) => deepEqual(aObj[key], bObj[key])); } /** * Find a form value from params and/or state. * Useful in action handlers to locate form input values regardless of path format. * * Checks in order: * 1. Direct param key (if not a path reference) * 2. Param keys ending with the field name * 3. State keys ending with the field name (dot notation) * 4. State path using getByPath (slash notation) * * @example * // Find "name" from params or state * const name = findFormValue("name", params, state); * * // Will find from: params.name, params["form.name"], state["form.name"], or getByPath(state, "name") */ export function findFormValue( fieldName: string, params?: Record, state?: Record, ): unknown { // Check params first (but not if it looks like a state path reference) if (params?.[fieldName] !== undefined) { const val = params[fieldName]; // If the value looks like a path reference (contains dots), skip it if (typeof val !== "string" || !val.includes(".")) { return val; } } // Check param keys that end with the field name if (params) { for (const key of Object.keys(params)) { if (key.endsWith(`.${fieldName}`)) { const val = params[key]; if (typeof val !== "string" || !val.includes(".")) { return val; } } } } // Check state keys that end with the field name (handles any form naming) if (state) { for (const key of Object.keys(state)) { if (key === fieldName || key.endsWith(`.${fieldName}`)) { return state[key]; } } // Try getByPath with the raw field name const val = getByPath(state, fieldName); if (val !== undefined) { return val; } } return undefined; } // ============================================================================= // SpecStream - Streaming format for progressively building specs // ============================================================================= /** * A SpecStream line - a single patch operation in the stream. */ export type SpecStreamLine = JsonPatch; /** * Parse a single SpecStream line into a patch operation. * Returns null if the line is invalid or empty. * * SpecStream is json-render's streaming format where each line is a JSON patch * operation that progressively builds up the final spec. */ export function parseSpecStreamLine(line: string): SpecStreamLine | null { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith("{")) return null; try { const patch = JSON.parse(trimmed) as SpecStreamLine; if (patch.op && patch.path !== undefined) { return patch; } return null; } catch { return null; } } /** * Apply a single RFC 6902 JSON Patch operation to an object. * Mutates the object in place. * * Supports all six RFC 6902 operations: add, remove, replace, move, copy, test. * * @throws {Error} If a "test" operation fails (value mismatch). */ export function applySpecStreamPatch>( obj: T, patch: SpecStreamLine, ): T { switch (patch.op) { case "add": addByPath(obj, patch.path, patch.value); break; case "replace": // RFC 6902: target must exist. For streaming tolerance we set regardless. setByPath(obj, patch.path, patch.value); break; case "remove": removeByPath(obj, patch.path); break; case "move": { if (!patch.from) break; const moveValue = getByPath(obj, patch.from); removeByPath(obj, patch.from); addByPath(obj, patch.path, moveValue); break; } case "copy": { if (!patch.from) break; const copyValue = getByPath(obj, patch.from); addByPath(obj, patch.path, copyValue); break; } case "test": { const actual = getByPath(obj, patch.path); if (!deepEqual(actual, patch.value)) { throw new Error( `Test operation failed: value at "${patch.path}" does not match`, ); } break; } } return obj; } /** * Apply a single RFC 6902 JSON Patch operation to a Spec. * Mutates the spec in place and returns it. * * This is a typed convenience wrapper around `applySpecStreamPatch` that * accepts a `Spec` directly without requiring a cast to `Record`. * * Note: This mutates the spec. For React state updates, spread the result * to create a new reference: `setSpec({ ...applySpecPatch(spec, patch) })`. * * @example * let spec: Spec = { root: "", elements: {} }; * applySpecPatch(spec, { op: "add", path: "/root", value: "main" }); */ export function applySpecPatch(spec: Spec, patch: SpecStreamLine): Spec { applySpecStreamPatch(spec as unknown as Record, patch); return spec; } // ============================================================================= // Nested-to-Flat Conversion // ============================================================================= /** * A nested spec node. This is the tree format that humans naturally write — * each node has inline `children` as an array of child node objects rather * than string keys. */ interface NestedNode { type: string; props: Record; children?: NestedNode[]; /** Any other top-level fields (visible, on, repeat, etc.) */ [key: string]: unknown; } /** * Convert a nested (tree-structured) spec into the flat `Spec` format used * by json-render renderers. * * In the nested format each node has inline `children` as an array of child * objects. This function walks the tree, assigns auto-generated keys * (`el-0`, `el-1`, ...), and produces a flat `{ root, elements, state }` spec. * * The top-level `state` field (if present on the root node) is hoisted to * `spec.state`. * * @example * ```ts * const nested = { * type: "Card", * props: { title: "Hello" }, * children: [ * { type: "Text", props: { content: "World" } }, * ], * state: { count: 0 }, * }; * const spec = nestedToFlat(nested); * // { * // root: "el-0", * // elements: { * // "el-0": { type: "Card", props: { title: "Hello" }, children: ["el-1"] }, * // "el-1": { type: "Text", props: { content: "World" }, children: [] }, * // }, * // state: { count: 0 }, * // } * ``` */ export function nestedToFlat(nested: Record): Spec { const elements: Record = {}; let counter = 0; function walk(node: Record): string { const key = `el-${counter++}`; const { type, props, children: rawChildren, ...rest } = node as NestedNode; // Recursively flatten children const childKeys: string[] = []; if (Array.isArray(rawChildren)) { for (const child of rawChildren) { if (child && typeof child === "object" && "type" in child) { childKeys.push(walk(child as Record)); } } } // Build the flat element, preserving extra fields (visible, on, repeat, etc.) // but excluding `state` which is hoisted to spec-level. const element: UIElement = { type: type ?? "unknown", props: (props as Record) ?? {}, children: childKeys, }; // Copy extra fields (visible, on, repeat) but not state for (const [k, v] of Object.entries(rest)) { if (k !== "state" && v !== undefined) { (element as unknown as Record)[k] = v; } } elements[key] = element; return key; } const root = walk(nested); const spec: Spec = { root, elements }; // Hoist state from root node if present if ( nested.state && typeof nested.state === "object" && !Array.isArray(nested.state) ) { spec.state = nested.state as Record; } return spec; } /** * Compile a SpecStream string into a JSON object. * Each line should be a patch operation. * * @example * const stream = `{"op":"add","path":"/name","value":"Alice"} * {"op":"add","path":"/age","value":30}`; * const result = compileSpecStream(stream); * // { name: "Alice", age: 30 } */ export function compileSpecStream< T extends Record = Record, >(stream: string, initial: T = {} as T): T { const lines = stream.split("\n"); const result = { ...initial }; for (const line of lines) { const patch = parseSpecStreamLine(line); if (patch) { applySpecStreamPatch(result, patch); } } return result as T; } /** * Streaming SpecStream compiler. * Useful for processing SpecStream data as it streams in from AI. * * @example * const compiler = createSpecStreamCompiler(); * * // As chunks arrive: * const { result, newPatches } = compiler.push(chunk); * if (newPatches.length > 0) { * updateUI(result); * } * * // When done: * const finalResult = compiler.getResult(); */ export interface SpecStreamCompiler { /** Push a chunk of text. Returns the current result and any new patches applied. */ push(chunk: string): { result: T; newPatches: SpecStreamLine[] }; /** Get the current compiled result */ getResult(): T; /** Get all patches that have been applied */ getPatches(): SpecStreamLine[]; /** Reset the compiler to initial state */ reset(initial?: Partial): void; } /** * Create a streaming SpecStream compiler. * * SpecStream is json-render's streaming format. AI outputs patch operations * line by line, and this compiler progressively builds the final spec. * * @example * const compiler = createSpecStreamCompiler(); * * // Process streaming response * const reader = response.body.getReader(); * while (true) { * const { done, value } = await reader.read(); * if (done) break; * * const { result, newPatches } = compiler.push(decoder.decode(value)); * if (newPatches.length > 0) { * setSpec(result); // Update UI with partial result * } * } */ export function createSpecStreamCompiler>( initial: Partial = {}, ): SpecStreamCompiler { let result = { ...initial } as T; let buffer = ""; const appliedPatches: SpecStreamLine[] = []; const processedLines = new Set(); return { push(chunk: string): { result: T; newPatches: SpecStreamLine[] } { buffer += chunk; const newPatches: SpecStreamLine[] = []; // Process complete lines const lines = buffer.split("\n"); buffer = lines.pop() || ""; // Keep incomplete line in buffer for (const line of lines) { const trimmed = line.trim(); if (!trimmed || processedLines.has(trimmed)) continue; processedLines.add(trimmed); const patch = parseSpecStreamLine(trimmed); if (patch) { applySpecStreamPatch(result as Record, patch); appliedPatches.push(patch); newPatches.push(patch); } } // Return a shallow copy to trigger re-renders if (newPatches.length > 0) { result = { ...result }; } return { result, newPatches }; }, getResult(): T { // Process any remaining buffer if (buffer.trim()) { const patch = parseSpecStreamLine(buffer); if (patch && !processedLines.has(buffer.trim())) { processedLines.add(buffer.trim()); applySpecStreamPatch(result as Record, patch); appliedPatches.push(patch); result = { ...result }; } buffer = ""; } return result; }, getPatches(): SpecStreamLine[] { return [...appliedPatches]; }, reset(newInitial: Partial = {}): void { result = { ...newInitial } as T; buffer = ""; appliedPatches.length = 0; processedLines.clear(); }, }; } // ============================================================================= // Mixed Stream Parser — for chat + GenUI (text interleaved with JSONL patches) // ============================================================================= /** * Callbacks for the mixed stream parser. */ export interface MixedStreamCallbacks { /** Called when a JSONL patch line is parsed */ onPatch: (patch: SpecStreamLine) => void; /** Called when a text (non-JSONL) line is received */ onText: (text: string) => void; } /** * A stateful parser for mixed streams that contain both text and JSONL patches. * Used in chat + GenUI scenarios where an LLM responds with conversational text * interleaved with json-render JSONL patch operations. */ export interface MixedStreamParser { /** Push a chunk of streamed data. Calls onPatch/onText for each complete line. */ push(chunk: string): void; /** Flush any remaining buffered content. Call when the stream ends. */ flush(): void; } /** * Create a parser for mixed text + JSONL streams. * * In chat + GenUI scenarios, an LLM streams a response that contains both * conversational text and json-render JSONL patch lines. This parser buffers * incoming chunks, splits them into lines, and classifies each line as either * a JSONL patch (via `parseSpecStreamLine`) or plain text. * * @example * const parser = createMixedStreamParser({ * onText: (text) => appendToMessage(text), * onPatch: (patch) => applySpecPatch(spec, patch), * }); * * // As chunks arrive from the stream: * for await (const chunk of stream) { * parser.push(chunk); * } * parser.flush(); */ export function createMixedStreamParser( callbacks: MixedStreamCallbacks, ): MixedStreamParser { let buffer = ""; let inSpecFence = false; function processLine(line: string): void { const trimmed = line.trim(); // Fence detection if (!inSpecFence && trimmed.startsWith("```spec")) { inSpecFence = true; return; } if (inSpecFence && trimmed === "```") { inSpecFence = false; return; } if (!trimmed) return; if (inSpecFence) { const patch = parseSpecStreamLine(trimmed); if (patch) { callbacks.onPatch(patch); } return; } // Outside fence: heuristic mode const patch = parseSpecStreamLine(trimmed); if (patch) { callbacks.onPatch(patch); } else { callbacks.onText(line); } } return { push(chunk: string): void { buffer += chunk; // Process complete lines const lines = buffer.split("\n"); buffer = lines.pop() || ""; // Keep incomplete line in buffer for (const line of lines) { processLine(line); } }, flush(): void { if (buffer.trim()) { processLine(buffer); } buffer = ""; }, }; } // ============================================================================= // AI SDK Stream Transform // ============================================================================= /** * Minimal chunk shape compatible with the AI SDK's `UIMessageChunk`. * * Defined here so that `@json-render/core` has no dependency on the `ai` * package. The discriminated union covers the three text-related chunk types * the transform inspects; all other chunk types pass through via the fallback. */ export type StreamChunk = | { type: "text-start"; id: string; [k: string]: unknown } | { type: "text-delta"; id: string; delta: string; [k: string]: unknown } | { type: "text-end"; id: string; [k: string]: unknown } | { type: string; [k: string]: unknown }; /** The opening fence for a spec block (e.g. ` ```spec `). */ const SPEC_FENCE_OPEN = "```spec"; /** The closing fence for a spec block. */ const SPEC_FENCE_CLOSE = "```"; /** * Creates a `TransformStream` that intercepts AI SDK UI message stream chunks * and classifies text content as either prose or json-render JSONL patches. * * Two classification modes: * * 1. **Fence mode** (preferred): Lines between ` ```spec ` and ` ``` ` are * parsed as JSONL patches. Fence delimiters are swallowed (not emitted). * 2. **Heuristic mode** (backward compat): Outside of fences, lines starting * with `{` are buffered and tested with `parseSpecStreamLine`. Valid patches * are emitted as {@link SPEC_DATA_PART_TYPE} parts; everything else is * flushed as text. * * Non-text chunks (tool events, step markers, etc.) are passed through unchanged. * * @example * ```ts * import { createJsonRenderTransform } from "@json-render/core"; * import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; * * const stream = createUIMessageStream({ * execute: async ({ writer }) => { * writer.merge( * result.toUIMessageStream().pipeThrough(createJsonRenderTransform()), * ); * }, * }); * return createUIMessageStreamResponse({ stream }); * ``` */ export function createJsonRenderTransform(): TransformStream< StreamChunk, StreamChunk > { let lineBuffer = ""; let currentTextId = ""; // Whether the current incomplete line might be JSONL (starts with '{') let buffering = false; // Whether we are inside a ```spec fence let inSpecFence = false; // Whether we are currently inside a text block (between text-start/text-end). // Used to split text blocks around spec data so the AI SDK creates separate // text parts, preserving interleaving of prose and UI in message.parts. let inTextBlock = false; let textIdCounter = 0; /** Close the current text block if one is open. */ function closeTextBlock( controller: TransformStreamDefaultController, ) { if (inTextBlock) { controller.enqueue({ type: "text-end", id: currentTextId }); inTextBlock = false; } } /** Ensure a text block is open, starting a new one if needed. */ function ensureTextBlock( controller: TransformStreamDefaultController, ) { if (!inTextBlock) { textIdCounter++; currentTextId = String(textIdCounter); controller.enqueue({ type: "text-start", id: currentTextId }); inTextBlock = true; } } /** Emit a text-delta, opening a text block first if necessary. */ function emitTextDelta( delta: string, controller: TransformStreamDefaultController, ) { ensureTextBlock(controller); controller.enqueue({ type: "text-delta", id: currentTextId, delta }); } function emitPatch( patch: SpecStreamLine, controller: TransformStreamDefaultController, ) { closeTextBlock(controller); controller.enqueue({ type: SPEC_DATA_PART_TYPE, data: { type: "patch", patch }, }); } function flushBuffer( controller: TransformStreamDefaultController, ) { if (!lineBuffer) return; const trimmed = lineBuffer.trim(); // Inside a fence, everything is spec data if (inSpecFence) { if (trimmed) { const patch = parseSpecStreamLine(trimmed); if (patch) emitPatch(patch, controller); // Non-patch lines inside the fence are silently dropped } lineBuffer = ""; buffering = false; return; } if (trimmed) { const patch = parseSpecStreamLine(trimmed); if (patch) { emitPatch(patch, controller); } else { // Was buffered but isn't JSONL — flush as text emitTextDelta(lineBuffer, controller); } } else { // Whitespace-only buffer — forward as-is (preserves blank lines) emitTextDelta(lineBuffer, controller); } lineBuffer = ""; buffering = false; } function processCompleteLine( line: string, controller: TransformStreamDefaultController, ) { const trimmed = line.trim(); // --- Fence detection --- if (!inSpecFence && trimmed.startsWith(SPEC_FENCE_OPEN)) { inSpecFence = true; return; // Swallow the opening fence } if (inSpecFence && trimmed === SPEC_FENCE_CLOSE) { inSpecFence = false; return; // Swallow the closing fence } // Inside a fence: parse as spec data if (inSpecFence) { if (trimmed) { const patch = parseSpecStreamLine(trimmed); if (patch) emitPatch(patch, controller); } return; } // --- Outside fence: heuristic mode --- if (!trimmed) { // Empty line — forward for markdown paragraph breaks emitTextDelta("\n", controller); return; } const patch = parseSpecStreamLine(trimmed); if (patch) { emitPatch(patch, controller); } else { emitTextDelta(line + "\n", controller); } } return new TransformStream({ transform(chunk, controller) { switch (chunk.type) { case "text-start": { const id = (chunk as { id: string }).id; const idNum = parseInt(id, 10); if (!isNaN(idNum) && idNum >= textIdCounter) { textIdCounter = idNum; } currentTextId = id; inTextBlock = true; controller.enqueue(chunk); break; } case "text-delta": { const delta = chunk as { id: string; delta: string }; const text = delta.delta; for (let i = 0; i < text.length; i++) { const ch = text.charAt(i); if (ch === "\n") { // Line complete — classify and emit if (buffering) { processCompleteLine(lineBuffer, controller); lineBuffer = ""; buffering = false; } else { // Outside fence, emit newline; inside fence, swallow it if (!inSpecFence) { emitTextDelta("\n", controller); } } } else if (lineBuffer.length === 0 && !buffering) { // Start of a new line — decide whether to buffer or stream if (inSpecFence || ch === "{" || ch === "`") { // Buffer: inside fence (everything), or heuristic mode ({), or potential fence (`) buffering = true; lineBuffer += ch; } else { emitTextDelta(ch, controller); } } else if (buffering) { lineBuffer += ch; } else { emitTextDelta(ch, controller); } } break; } case "text-end": { flushBuffer(controller); if (inTextBlock) { controller.enqueue({ type: "text-end", id: currentTextId }); inTextBlock = false; } break; } default: { controller.enqueue(chunk); break; } } }, flush(controller) { flushBuffer(controller); closeTextBlock(controller); }, }); } /** * The key registered in `AppDataParts` for json-render specs. * The AI SDK automatically prefixes this with `"data-"` on the wire, * so the actual stream chunk type is `"data-spec"` (see {@link SPEC_DATA_PART_TYPE}). * * @example * ```ts * import { SPEC_DATA_PART, type SpecDataPart } from "@json-render/core"; * type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart }; * ``` */ export const SPEC_DATA_PART = "spec" as const; /** * The wire-format type string as it appears in stream chunks and message parts. * This is `"data-"` + {@link SPEC_DATA_PART} — i.e. `"data-spec"`. * * Use this constant when filtering message parts or enqueuing stream chunks. */ export const SPEC_DATA_PART_TYPE = `data-${SPEC_DATA_PART}` as const; /** * Discriminated union for the payload of a {@link SPEC_DATA_PART_TYPE} SSE part. * * - `"patch"`: A single RFC 6902 JSON Patch operation (streaming, progressive UI). * - `"flat"`: A complete flat spec with `root`, `elements`, and optional `state`. * - `"nested"`: A complete nested spec (tree structure — schema depends on catalog). */ export type SpecDataPart = | { type: "patch"; patch: JsonPatch } | { type: "flat"; spec: Spec } | { type: "nested"; spec: Record }; /** * Convenience wrapper that pipes an AI SDK UI message stream through the * json-render transform, classifying text as prose or JSONL patches. * * Eliminates the need for manual `pipeThrough(createJsonRenderTransform())` * and the associated type cast. * * @example * ```ts * import { pipeJsonRender } from "@json-render/core"; * * const stream = createUIMessageStream({ * execute: async ({ writer }) => { * writer.merge(pipeJsonRender(result.toUIMessageStream())); * }, * }); * return createUIMessageStreamResponse({ stream }); * ``` */ export function pipeJsonRender( stream: ReadableStream, ): ReadableStream { return stream.pipeThrough( createJsonRenderTransform() as unknown as TransformStream, ); } ================================================ FILE: packages/core/src/validation.test.ts ================================================ import { describe, it, expect } from "vitest"; import { builtInValidationFunctions, runValidationCheck, runValidation, check, } from "./validation"; describe("builtInValidationFunctions", () => { describe("required", () => { it("passes for non-empty values", () => { expect(builtInValidationFunctions.required("hello")).toBe(true); expect(builtInValidationFunctions.required(0)).toBe(true); expect(builtInValidationFunctions.required(false)).toBe(true); expect(builtInValidationFunctions.required(["item"])).toBe(true); expect(builtInValidationFunctions.required({ key: "value" })).toBe(true); }); it("fails for empty values", () => { expect(builtInValidationFunctions.required("")).toBe(false); expect(builtInValidationFunctions.required(" ")).toBe(false); expect(builtInValidationFunctions.required(null)).toBe(false); expect(builtInValidationFunctions.required(undefined)).toBe(false); expect(builtInValidationFunctions.required([])).toBe(false); }); }); describe("email", () => { it("passes for valid emails", () => { expect(builtInValidationFunctions.email("test@example.com")).toBe(true); expect(builtInValidationFunctions.email("user.name@domain.co")).toBe( true, ); expect(builtInValidationFunctions.email("a@b.c")).toBe(true); }); it("fails for invalid emails", () => { expect(builtInValidationFunctions.email("invalid")).toBe(false); expect(builtInValidationFunctions.email("missing@domain")).toBe(false); expect(builtInValidationFunctions.email("@domain.com")).toBe(false); expect(builtInValidationFunctions.email("user@")).toBe(false); expect(builtInValidationFunctions.email(123)).toBe(false); }); }); describe("minLength", () => { it("passes when string meets minimum length", () => { expect(builtInValidationFunctions.minLength("hello", { min: 3 })).toBe( true, ); expect(builtInValidationFunctions.minLength("abc", { min: 3 })).toBe( true, ); expect(builtInValidationFunctions.minLength("abcdef", { min: 3 })).toBe( true, ); }); it("fails when string is too short", () => { expect(builtInValidationFunctions.minLength("hi", { min: 3 })).toBe( false, ); expect(builtInValidationFunctions.minLength("", { min: 1 })).toBe(false); }); it("fails for non-strings", () => { expect(builtInValidationFunctions.minLength(123, { min: 1 })).toBe(false); }); it("fails when min is not provided", () => { expect(builtInValidationFunctions.minLength("hello", {})).toBe(false); }); }); describe("maxLength", () => { it("passes when string meets maximum length", () => { expect(builtInValidationFunctions.maxLength("hi", { max: 5 })).toBe(true); expect(builtInValidationFunctions.maxLength("hello", { max: 5 })).toBe( true, ); }); it("fails when string exceeds maximum", () => { expect(builtInValidationFunctions.maxLength("hello!", { max: 5 })).toBe( false, ); }); }); describe("pattern", () => { it("passes when string matches pattern", () => { expect( builtInValidationFunctions.pattern("abc123", { pattern: "^[a-z0-9]+$", }), ).toBe(true); }); it("fails when string does not match pattern", () => { expect( builtInValidationFunctions.pattern("ABC", { pattern: "^[a-z]+$" }), ).toBe(false); }); it("fails for invalid regex pattern", () => { expect( builtInValidationFunctions.pattern("test", { pattern: "[invalid" }), ).toBe(false); }); }); describe("min", () => { it("passes when number meets minimum", () => { expect(builtInValidationFunctions.min(5, { min: 3 })).toBe(true); expect(builtInValidationFunctions.min(3, { min: 3 })).toBe(true); }); it("fails when number is below minimum", () => { expect(builtInValidationFunctions.min(2, { min: 3 })).toBe(false); }); it("fails for non-numbers", () => { expect(builtInValidationFunctions.min("5", { min: 3 })).toBe(false); }); }); describe("max", () => { it("passes when number meets maximum", () => { expect(builtInValidationFunctions.max(3, { max: 5 })).toBe(true); expect(builtInValidationFunctions.max(5, { max: 5 })).toBe(true); }); it("fails when number exceeds maximum", () => { expect(builtInValidationFunctions.max(6, { max: 5 })).toBe(false); }); }); describe("numeric", () => { it("passes for numbers", () => { expect(builtInValidationFunctions.numeric(42)).toBe(true); expect(builtInValidationFunctions.numeric(3.14)).toBe(true); expect(builtInValidationFunctions.numeric(0)).toBe(true); }); it("passes for numeric strings", () => { expect(builtInValidationFunctions.numeric("42")).toBe(true); expect(builtInValidationFunctions.numeric("3.14")).toBe(true); }); it("fails for non-numeric values", () => { expect(builtInValidationFunctions.numeric("abc")).toBe(false); expect(builtInValidationFunctions.numeric(NaN)).toBe(false); expect(builtInValidationFunctions.numeric(null)).toBe(false); }); }); describe("url", () => { it("passes for valid URLs", () => { expect(builtInValidationFunctions.url("https://example.com")).toBe(true); expect(builtInValidationFunctions.url("http://localhost:3000")).toBe( true, ); expect( builtInValidationFunctions.url("https://example.com/path?query=1"), ).toBe(true); }); it("fails for invalid URLs", () => { expect(builtInValidationFunctions.url("not-a-url")).toBe(false); expect(builtInValidationFunctions.url("example.com")).toBe(false); }); }); describe("matches", () => { it("passes when values match", () => { expect( builtInValidationFunctions.matches("password", { other: "password" }), ).toBe(true); expect(builtInValidationFunctions.matches(123, { other: 123 })).toBe( true, ); }); it("fails when values do not match", () => { expect( builtInValidationFunctions.matches("password", { other: "different" }), ).toBe(false); }); }); describe("equalTo", () => { it("passes when values are equal", () => { expect(builtInValidationFunctions.equalTo("abc", { other: "abc" })).toBe( true, ); }); it("fails when values differ", () => { expect(builtInValidationFunctions.equalTo("abc", { other: "xyz" })).toBe( false, ); }); }); describe("lessThan", () => { it("passes when value is less than other", () => { expect(builtInValidationFunctions.lessThan(3, { other: 5 })).toBe(true); }); it("fails when value equals other", () => { expect(builtInValidationFunctions.lessThan(5, { other: 5 })).toBe(false); }); it("fails when value is greater than other", () => { expect(builtInValidationFunctions.lessThan(7, { other: 5 })).toBe(false); }); it("coerces numeric string vs number", () => { expect(builtInValidationFunctions.lessThan("3", { other: 5 })).toBe(true); }); it("fails coercion when non-numeric string", () => { expect(builtInValidationFunctions.lessThan("abc", { other: 5 })).toBe( false, ); }); it("passes for string comparison (ISO dates)", () => { expect( builtInValidationFunctions.lessThan("2026-01-01", { other: "2026-06-15", }), ).toBe(true); }); it("fails for equal strings", () => { expect( builtInValidationFunctions.lessThan("2026-01-01", { other: "2026-01-01", }), ).toBe(false); }); it("returns false when value is empty string", () => { expect(builtInValidationFunctions.lessThan("", { other: 5 })).toBe(false); }); it("returns false when other is empty string", () => { expect(builtInValidationFunctions.lessThan(3, { other: "" })).toBe(false); }); it("returns false when value is empty string vs non-empty string", () => { expect(builtInValidationFunctions.lessThan("", { other: "abc" })).toBe( false, ); }); it("returns false when other is empty string vs non-empty string", () => { expect(builtInValidationFunctions.lessThan("abc", { other: "" })).toBe( false, ); }); it("returns false when other is null", () => { expect(builtInValidationFunctions.lessThan(3, { other: null })).toBe( false, ); }); it("returns false when value is null", () => { expect(builtInValidationFunctions.lessThan(null, { other: 5 })).toBe( false, ); }); it("returns false when other is undefined", () => { expect(builtInValidationFunctions.lessThan(3, { other: undefined })).toBe( false, ); }); }); describe("greaterThan", () => { it("passes when value is greater than other", () => { expect(builtInValidationFunctions.greaterThan(7, { other: 5 })).toBe( true, ); }); it("fails when value equals other", () => { expect(builtInValidationFunctions.greaterThan(5, { other: 5 })).toBe( false, ); }); it("fails when value is less than other", () => { expect(builtInValidationFunctions.greaterThan(3, { other: 5 })).toBe( false, ); }); it("coerces numeric string vs number", () => { expect(builtInValidationFunctions.greaterThan("7", { other: 5 })).toBe( true, ); }); it("fails coercion when non-numeric string", () => { expect(builtInValidationFunctions.greaterThan("abc", { other: 5 })).toBe( false, ); }); it("passes for string comparison (ISO dates)", () => { expect( builtInValidationFunctions.greaterThan("2026-06-15", { other: "2026-01-01", }), ).toBe(true); }); it("fails for lesser strings", () => { expect( builtInValidationFunctions.greaterThan("2026-01-01", { other: "2026-06-15", }), ).toBe(false); }); it("returns false when value is empty string", () => { expect(builtInValidationFunctions.greaterThan("", { other: 5 })).toBe( false, ); }); it("returns false when other is empty string", () => { expect(builtInValidationFunctions.greaterThan(3, { other: "" })).toBe( false, ); }); it("returns false when value is empty string vs non-empty string", () => { expect(builtInValidationFunctions.greaterThan("", { other: "abc" })).toBe( false, ); }); it("returns false when other is empty string vs non-empty string", () => { expect(builtInValidationFunctions.greaterThan("abc", { other: "" })).toBe( false, ); }); it("returns false when other is null", () => { expect(builtInValidationFunctions.greaterThan(3, { other: null })).toBe( false, ); }); it("returns false when value is undefined", () => { expect( builtInValidationFunctions.greaterThan(undefined, { other: 5 }), ).toBe(false); }); it("returns false when value is null", () => { expect(builtInValidationFunctions.greaterThan(null, { other: 5 })).toBe( false, ); }); }); describe("requiredIf", () => { it("passes when condition is falsy (field not required)", () => { expect(builtInValidationFunctions.requiredIf("", { field: false })).toBe( true, ); expect(builtInValidationFunctions.requiredIf("", { field: "" })).toBe( true, ); expect(builtInValidationFunctions.requiredIf("", { field: null })).toBe( true, ); expect( builtInValidationFunctions.requiredIf("", { field: undefined }), ).toBe(true); }); it("fails when condition is truthy and value is empty", () => { expect(builtInValidationFunctions.requiredIf("", { field: true })).toBe( false, ); expect( builtInValidationFunctions.requiredIf(null, { field: "yes" }), ).toBe(false); expect( builtInValidationFunctions.requiredIf(undefined, { field: 1 }), ).toBe(false); }); it("passes when condition is truthy and value is present", () => { expect( builtInValidationFunctions.requiredIf("hello", { field: true }), ).toBe(true); expect(builtInValidationFunctions.requiredIf(42, { field: true })).toBe( true, ); }); }); }); describe("runValidationCheck", () => { it("runs a validation check and returns result", () => { const result = runValidationCheck( { type: "required", message: "Required" }, { value: "hello", stateModel: {} }, ); expect(result.type).toBe("required"); expect(result.valid).toBe(true); expect(result.message).toBe("Required"); }); it("resolves dynamic args from stateModel", () => { const result = runValidationCheck( { type: "minLength", args: { min: { $state: "/minLen" } }, message: "Too short", }, { value: "hi", stateModel: { minLen: 5 } }, ); expect(result.valid).toBe(false); }); it("returns valid for unknown functions with warning", () => { const result = runValidationCheck( { type: "unknownFunction", message: "Unknown" }, { value: "test", stateModel: {} }, ); expect(result.valid).toBe(true); }); it("uses custom validation functions", () => { const customFunctions = { startsWithA: (value: unknown) => typeof value === "string" && value.startsWith("A"), }; const result = runValidationCheck( { type: "startsWithA", message: "Must start with A" }, { value: "Apple", stateModel: {}, customFunctions }, ); expect(result.valid).toBe(true); }); }); describe("runValidation", () => { it("runs all validation checks", () => { const result = runValidation( { checks: [ { type: "required", message: "Required" }, { type: "email", message: "Invalid email" }, ], }, { value: "test@example.com", stateModel: {} }, ); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); expect(result.checks).toHaveLength(2); }); it("collects all errors", () => { const result = runValidation( { checks: [ { type: "required", message: "Required" }, { type: "email", message: "Invalid email" }, ], }, { value: "", stateModel: {} }, ); expect(result.valid).toBe(false); expect(result.errors).toContain("Required"); expect(result.errors).toContain("Invalid email"); }); it("skips validation when enabled condition is false", () => { const result = runValidation( { checks: [{ type: "required", message: "Required" }], enabled: { $state: "/enabled", eq: true }, // False because /enabled is not set }, { value: "", stateModel: {} }, ); expect(result.valid).toBe(true); expect(result.checks).toHaveLength(0); }); it("runs validation when enabled condition is true", () => { const result = runValidation( { checks: [{ type: "required", message: "Required" }], enabled: { $state: "/enabled" }, // True because /enabled is truthy }, { value: "", stateModel: { enabled: true } }, ); expect(result.valid).toBe(false); }); it("returns valid when no checks defined", () => { const result = runValidation({}, { value: "", stateModel: {} }); expect(result.valid).toBe(true); expect(result.checks).toHaveLength(0); }); }); describe("check helper", () => { describe("required", () => { it("creates required check with default message", () => { const c = check.required(); expect(c.type).toBe("required"); expect(c.message).toBe("This field is required"); }); it("creates required check with custom message", () => { const c = check.required("Custom message"); expect(c.message).toBe("Custom message"); }); }); describe("email", () => { it("creates email check with default message", () => { const c = check.email(); expect(c.type).toBe("email"); expect(c.message).toBe("Invalid email address"); }); }); describe("minLength", () => { it("creates minLength check with args", () => { const c = check.minLength(5, "Too short"); expect(c.type).toBe("minLength"); expect(c.args).toEqual({ min: 5 }); expect(c.message).toBe("Too short"); }); it("uses default message", () => { const c = check.minLength(3); expect(c.message).toBe("Must be at least 3 characters"); }); }); describe("maxLength", () => { it("creates maxLength check with args", () => { const c = check.maxLength(100); expect(c.type).toBe("maxLength"); expect(c.args).toEqual({ max: 100 }); }); }); describe("pattern", () => { it("creates pattern check", () => { const c = check.pattern("^[a-z]+$", "Letters only"); expect(c.type).toBe("pattern"); expect(c.args).toEqual({ pattern: "^[a-z]+$" }); expect(c.message).toBe("Letters only"); }); }); describe("min", () => { it("creates min check", () => { const c = check.min(0, "Must be positive"); expect(c.type).toBe("min"); expect(c.args).toEqual({ min: 0 }); }); }); describe("max", () => { it("creates max check", () => { const c = check.max(100); expect(c.type).toBe("max"); expect(c.args).toEqual({ max: 100 }); }); }); describe("url", () => { it("creates url check", () => { const c = check.url("Must be a URL"); expect(c.type).toBe("url"); expect(c.message).toBe("Must be a URL"); }); }); describe("numeric", () => { it("creates numeric check with default message", () => { const c = check.numeric(); expect(c.type).toBe("numeric"); expect(c.message).toBe("Must be a number"); }); it("creates numeric check with custom message", () => { const c = check.numeric("Numbers only"); expect(c.type).toBe("numeric"); expect(c.message).toBe("Numbers only"); }); }); describe("matches", () => { it("creates matches check with path reference", () => { const c = check.matches("/password", "Passwords must match"); expect(c.type).toBe("matches"); expect(c.args).toEqual({ other: { $state: "/password" } }); expect(c.message).toBe("Passwords must match"); }); }); describe("equalTo", () => { it("creates equalTo check with path reference", () => { const c = check.equalTo("/email", "Emails must match"); expect(c.type).toBe("equalTo"); expect(c.args).toEqual({ other: { $state: "/email" } }); expect(c.message).toBe("Emails must match"); }); }); describe("lessThan", () => { it("creates lessThan check with path reference", () => { const c = check.lessThan("/maxValue", "Must be less"); expect(c.type).toBe("lessThan"); expect(c.args).toEqual({ other: { $state: "/maxValue" } }); expect(c.message).toBe("Must be less"); }); }); describe("greaterThan", () => { it("creates greaterThan check with path reference", () => { const c = check.greaterThan("/minValue"); expect(c.type).toBe("greaterThan"); expect(c.args).toEqual({ other: { $state: "/minValue" } }); }); }); describe("requiredIf", () => { it("creates requiredIf check with path reference", () => { const c = check.requiredIf("/toggle", "Required when toggle is on"); expect(c.type).toBe("requiredIf"); expect(c.args).toEqual({ field: { $state: "/toggle" } }); expect(c.message).toBe("Required when toggle is on"); }); }); }); // ============================================================================= // Deep arg resolution in runValidationCheck // ============================================================================= describe("deep arg resolution", () => { it("resolves nested $state refs in validation args", () => { const result = runValidationCheck( { type: "matches", args: { other: { $state: "/form/password" } }, message: "Passwords must match", }, { value: "secret123", stateModel: { form: { password: "secret123" } }, }, ); expect(result.valid).toBe(true); }); it("resolves $state in cross-field lessThan check", () => { const result = runValidationCheck( { type: "lessThan", args: { other: { $state: "/form/maxPrice" } }, message: "Must be less than max price", }, { value: 50, stateModel: { form: { maxPrice: 100 } }, }, ); expect(result.valid).toBe(true); }); it("resolves $state in requiredIf check", () => { const result = runValidationCheck( { type: "requiredIf", args: { field: { $state: "/form/enableEmail" } }, message: "Email is required", }, { value: "", stateModel: { form: { enableEmail: true } }, }, ); expect(result.valid).toBe(false); }); it("passes requiredIf when condition is false", () => { const result = runValidationCheck( { type: "requiredIf", args: { field: { $state: "/form/enableEmail" } }, message: "Email is required", }, { value: "", stateModel: { form: { enableEmail: false } }, }, ); expect(result.valid).toBe(true); }); }); ================================================ FILE: packages/core/src/validation.ts ================================================ import { z } from "zod"; import type { DynamicValue, StateModel, VisibilityCondition } from "./types"; import { DynamicValueSchema, resolveDynamicValue } from "./types"; import { VisibilityConditionSchema, evaluateVisibility } from "./visibility"; import { resolvePropValue } from "./props"; /** * Validation check definition */ export interface ValidationCheck { /** Validation type (built-in or from catalog) */ type: string; /** Additional arguments for the validation */ args?: Record; /** Error message to display if check fails */ message: string; } /** * Validation configuration for a field */ export interface ValidationConfig { /** Array of checks to run */ checks?: ValidationCheck[]; /** When to run validation */ validateOn?: "change" | "blur" | "submit"; /** Condition for when validation is enabled */ enabled?: VisibilityCondition; } /** * Schema for validation check */ export const ValidationCheckSchema = z.object({ type: z.string(), args: z.record(z.string(), DynamicValueSchema).optional(), message: z.string(), }); /** * Schema for validation config */ export const ValidationConfigSchema = z.object({ checks: z.array(ValidationCheckSchema).optional(), validateOn: z.enum(["change", "blur", "submit"]).optional(), enabled: VisibilityConditionSchema.optional(), }); /** * Validation function signature */ export type ValidationFunction = ( value: unknown, args?: Record, ) => boolean; /** * Validation function definition in catalog */ export interface ValidationFunctionDefinition { /** The validation function */ validate: ValidationFunction; /** Description for AI */ description?: string; } const matchesImpl: ValidationFunction = ( value: unknown, args?: Record, ) => { const other = args?.other; return value === other; }; /** * Built-in validation functions */ export const builtInValidationFunctions: Record = { /** * Check if value is not null, undefined, or empty string */ required: (value: unknown) => { if (value === null || value === undefined) return false; if (typeof value === "string") return value.trim().length > 0; if (Array.isArray(value)) return value.length > 0; return true; }, /** * Check if value is a valid email address */ email: (value: unknown) => { if (typeof value !== "string") return false; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); }, /** * Check minimum string length */ minLength: (value: unknown, args?: Record) => { if (typeof value !== "string") return false; const min = args?.min; if (typeof min !== "number") return false; return value.length >= min; }, /** * Check maximum string length */ maxLength: (value: unknown, args?: Record) => { if (typeof value !== "string") return false; const max = args?.max; if (typeof max !== "number") return false; return value.length <= max; }, /** * Check if string matches a regex pattern */ pattern: (value: unknown, args?: Record) => { if (typeof value !== "string") return false; const pattern = args?.pattern; if (typeof pattern !== "string") return false; try { return new RegExp(pattern).test(value); } catch { return false; } }, /** * Check minimum numeric value */ min: (value: unknown, args?: Record) => { if (typeof value !== "number") return false; const min = args?.min; if (typeof min !== "number") return false; return value >= min; }, /** * Check maximum numeric value */ max: (value: unknown, args?: Record) => { if (typeof value !== "number") return false; const max = args?.max; if (typeof max !== "number") return false; return value <= max; }, /** * Check if value is a number */ numeric: (value: unknown) => { if (typeof value === "number") return !isNaN(value); if (typeof value === "string") return !isNaN(parseFloat(value)); return false; }, /** * Check if value is a valid URL */ url: (value: unknown) => { if (typeof value !== "string") return false; try { new URL(value); return true; } catch { return false; } }, /** * Check if value matches another field */ matches: matchesImpl, /** * Alias for matches with a more descriptive name for cross-field equality */ equalTo: matchesImpl, /** * Check if value is less than another field's value. * Supports numbers, strings (useful for ISO date comparison), and * cross-type numeric coercion (e.g. string "3" vs number 5). */ lessThan: (value: unknown, args?: Record) => { const other = args?.other; if (value == null || other == null || value === "" || other === "") return false; if (typeof value === "number" && typeof other === "number") return value < other; if (typeof value === "string" && typeof other === "string") return value < other; const numVal = Number(value); const numOther = Number(other); if (!isNaN(numVal) && !isNaN(numOther)) return numVal < numOther; return false; }, /** * Check if value is greater than another field's value. * Supports numbers, strings (useful for ISO date comparison), and * cross-type numeric coercion (e.g. string "7" vs number 5). */ greaterThan: (value: unknown, args?: Record) => { const other = args?.other; if (value == null || other == null || value === "" || other === "") return false; if (typeof value === "number" && typeof other === "number") return value > other; if (typeof value === "string" && typeof other === "string") return value > other; const numVal = Number(value); const numOther = Number(other); if (!isNaN(numVal) && !isNaN(numOther)) return numVal > numOther; return false; }, /** * Required only when a condition is met. * Uses JS truthiness: 0, false, "", null, and undefined are all * treated as "condition not met" (field not required), matching * the visibility system's bare-condition semantics. */ requiredIf: (value: unknown, args?: Record) => { const condition = args?.field; if (!condition) return true; if (value === null || value === undefined) return false; if (typeof value === "string") return value.trim().length > 0; if (Array.isArray(value)) return value.length > 0; return true; }, }; /** * Validation result for a single check */ export interface ValidationCheckResult { type: string; valid: boolean; message: string; } /** * Full validation result for a field */ export interface ValidationResult { valid: boolean; errors: string[]; checks: ValidationCheckResult[]; } /** * Context for running validation */ export interface ValidationContext { /** Current value to validate */ value: unknown; /** Full data model for resolving paths */ stateModel: StateModel; /** Custom validation functions from catalog */ customFunctions?: Record; } /** * Run a single validation check */ export function runValidationCheck( check: ValidationCheck, ctx: ValidationContext, ): ValidationCheckResult { const { value, stateModel, customFunctions } = ctx; // Resolve args using resolvePropValue so nested $state refs (and any other // prop expressions) are handled consistently with the rest of the system. const resolvedArgs: Record = {}; if (check.args) { for (const [key, argValue] of Object.entries(check.args)) { resolvedArgs[key] = resolvePropValue(argValue, { stateModel }); } } // Find the validation function const validationFn = builtInValidationFunctions[check.type] ?? customFunctions?.[check.type]; if (!validationFn) { console.warn(`Unknown validation function: ${check.type}`); return { type: check.type, valid: true, // Don't fail on unknown functions message: check.message, }; } const valid = validationFn(value, resolvedArgs); return { type: check.type, valid, message: check.message, }; } /** * Run all validation checks for a field */ export function runValidation( config: ValidationConfig, ctx: ValidationContext, ): ValidationResult { const checks: ValidationCheckResult[] = []; const errors: string[] = []; // Check if validation is enabled if (config.enabled) { const enabled = evaluateVisibility(config.enabled, { stateModel: ctx.stateModel, }); if (!enabled) { return { valid: true, errors: [], checks: [] }; } } // Run each check if (config.checks) { for (const check of config.checks) { const result = runValidationCheck(check, ctx); checks.push(result); if (!result.valid) { errors.push(result.message); } } } return { valid: errors.length === 0, errors, checks, }; } /** * Helper to create validation checks */ export const check = { required: (message = "This field is required"): ValidationCheck => ({ type: "required", message, }), email: (message = "Invalid email address"): ValidationCheck => ({ type: "email", message, }), minLength: (min: number, message?: string): ValidationCheck => ({ type: "minLength", args: { min }, message: message ?? `Must be at least ${min} characters`, }), maxLength: (max: number, message?: string): ValidationCheck => ({ type: "maxLength", args: { max }, message: message ?? `Must be at most ${max} characters`, }), pattern: (pattern: string, message = "Invalid format"): ValidationCheck => ({ type: "pattern", args: { pattern }, message, }), min: (min: number, message?: string): ValidationCheck => ({ type: "min", args: { min }, message: message ?? `Must be at least ${min}`, }), max: (max: number, message?: string): ValidationCheck => ({ type: "max", args: { max }, message: message ?? `Must be at most ${max}`, }), url: (message = "Invalid URL"): ValidationCheck => ({ type: "url", message, }), numeric: (message = "Must be a number"): ValidationCheck => ({ type: "numeric", message, }), matches: ( otherPath: string, message = "Fields must match", ): ValidationCheck => ({ type: "matches", args: { other: { $state: otherPath } }, message, }), equalTo: ( otherPath: string, message = "Fields must match", ): ValidationCheck => ({ type: "equalTo", args: { other: { $state: otherPath } }, message, }), lessThan: (otherPath: string, message?: string): ValidationCheck => ({ type: "lessThan", args: { other: { $state: otherPath } }, message: message ?? "Must be less than the compared field", }), greaterThan: (otherPath: string, message?: string): ValidationCheck => ({ type: "greaterThan", args: { other: { $state: otherPath } }, message: message ?? "Must be greater than the compared field", }), requiredIf: ( fieldPath: string, message = "This field is required", ): ValidationCheck => ({ type: "requiredIf", args: { field: { $state: fieldPath } }, message, }), }; ================================================ FILE: packages/core/src/visibility.test.ts ================================================ import { describe, it, expect } from "vitest"; import { evaluateVisibility, visibility } from "./visibility"; describe("evaluateVisibility", () => { describe("undefined / boolean", () => { it("returns true for undefined", () => { expect(evaluateVisibility(undefined, { stateModel: {} })).toBe(true); }); it("returns true for true", () => { expect(evaluateVisibility(true, { stateModel: {} })).toBe(true); }); it("returns false for false", () => { expect(evaluateVisibility(false, { stateModel: {} })).toBe(false); }); }); describe("truthiness ($state only)", () => { it("returns true when state path is truthy (boolean)", () => { expect( evaluateVisibility( { $state: "/isAdmin" }, { stateModel: { isAdmin: true } }, ), ).toBe(true); }); it("returns true when state path is truthy (number)", () => { expect( evaluateVisibility({ $state: "/count" }, { stateModel: { count: 5 } }), ).toBe(true); }); it("returns true when state path is truthy (string)", () => { expect( evaluateVisibility( { $state: "/name" }, { stateModel: { name: "Alice" } }, ), ).toBe(true); }); it("returns false when state path is falsy (boolean)", () => { expect( evaluateVisibility( { $state: "/isAdmin" }, { stateModel: { isAdmin: false } }, ), ).toBe(false); }); it("returns false when state path is falsy (zero)", () => { expect( evaluateVisibility({ $state: "/count" }, { stateModel: { count: 0 } }), ).toBe(false); }); it("returns false when state path is falsy (empty string)", () => { expect( evaluateVisibility({ $state: "/name" }, { stateModel: { name: "" } }), ).toBe(false); }); it("returns false when state path is undefined", () => { expect( evaluateVisibility({ $state: "/nothing" }, { stateModel: {} }), ).toBe(false); }); it("returns false for missing path", () => { expect( evaluateVisibility( { $state: "/nonexistent" }, { stateModel: { other: true } }, ), ).toBe(false); }); }); describe("negation ($state + not)", () => { it("returns false when state path is truthy", () => { expect( evaluateVisibility( { $state: "/visible", not: true }, { stateModel: { visible: true } }, ), ).toBe(false); }); it("returns true when state path is falsy", () => { expect( evaluateVisibility( { $state: "/visible", not: true }, { stateModel: { visible: false } }, ), ).toBe(true); }); it("not inverts an eq condition", () => { expect( evaluateVisibility( { $state: "/tab", eq: "home", not: true }, { stateModel: { tab: "home" } }, ), ).toBe(false); expect( evaluateVisibility( { $state: "/tab", eq: "home", not: true }, { stateModel: { tab: "settings" } }, ), ).toBe(true); }); it("not inverts a gt condition", () => { expect( evaluateVisibility( { $state: "/count", gt: 5, not: true }, { stateModel: { count: 10 } }, ), ).toBe(false); expect( evaluateVisibility( { $state: "/count", gt: 5, not: true }, { stateModel: { count: 3 } }, ), ).toBe(true); }); }); describe("equality ($state + eq)", () => { it("returns true when values match (number)", () => { expect( evaluateVisibility( { $state: "/count", eq: 5 }, { stateModel: { count: 5 } }, ), ).toBe(true); }); it("returns false when values do not match", () => { expect( evaluateVisibility( { $state: "/count", eq: 10 }, { stateModel: { count: 5 } }, ), ).toBe(false); }); it("returns true when values match (string)", () => { expect( evaluateVisibility( { $state: "/tab", eq: "home" }, { stateModel: { tab: "home" } }, ), ).toBe(true); }); it("supports state-to-state comparison", () => { expect( evaluateVisibility( { $state: "/a", eq: { $state: "/b" } }, { stateModel: { a: 42, b: 42 } }, ), ).toBe(true); }); it("state-to-state comparison fails when different", () => { expect( evaluateVisibility( { $state: "/a", eq: { $state: "/b" } }, { stateModel: { a: 1, b: 2 } }, ), ).toBe(false); }); }); describe("inequality ($state + neq)", () => { it("returns true when values differ", () => { expect( evaluateVisibility( { $state: "/count", neq: 10 }, { stateModel: { count: 5 } }, ), ).toBe(true); }); it("returns false when values are equal", () => { expect( evaluateVisibility( { $state: "/count", neq: 5 }, { stateModel: { count: 5 } }, ), ).toBe(false); }); }); describe("numeric comparisons", () => { it("gt: returns true when greater", () => { expect( evaluateVisibility( { $state: "/count", gt: 3 }, { stateModel: { count: 5 } }, ), ).toBe(true); }); it("gt: returns false when less", () => { expect( evaluateVisibility( { $state: "/count", gt: 3 }, { stateModel: { count: 2 } }, ), ).toBe(false); }); it("gt: returns false when equal", () => { expect( evaluateVisibility( { $state: "/count", gt: 5 }, { stateModel: { count: 5 } }, ), ).toBe(false); }); it("gte: returns true when equal", () => { expect( evaluateVisibility( { $state: "/count", gte: 5 }, { stateModel: { count: 5 } }, ), ).toBe(true); }); it("gte: returns true when greater", () => { expect( evaluateVisibility( { $state: "/count", gte: 5 }, { stateModel: { count: 6 } }, ), ).toBe(true); }); it("gte: returns false when less", () => { expect( evaluateVisibility( { $state: "/count", gte: 5 }, { stateModel: { count: 4 } }, ), ).toBe(false); }); it("lt: returns true when less", () => { expect( evaluateVisibility( { $state: "/count", lt: 5 }, { stateModel: { count: 3 } }, ), ).toBe(true); }); it("lt: returns false when greater", () => { expect( evaluateVisibility( { $state: "/count", lt: 5 }, { stateModel: { count: 7 } }, ), ).toBe(false); }); it("lt: returns false when equal", () => { expect( evaluateVisibility( { $state: "/count", lt: 5 }, { stateModel: { count: 5 } }, ), ).toBe(false); }); it("lte: returns true when equal", () => { expect( evaluateVisibility( { $state: "/count", lte: 5 }, { stateModel: { count: 5 } }, ), ).toBe(true); }); it("lte: returns true when less", () => { expect( evaluateVisibility( { $state: "/count", lte: 5 }, { stateModel: { count: 4 } }, ), ).toBe(true); }); it("lte: returns false when greater", () => { expect( evaluateVisibility( { $state: "/count", lte: 5 }, { stateModel: { count: 6 } }, ), ).toBe(false); }); it("returns false for non-numeric values", () => { expect( evaluateVisibility( { $state: "/name", gt: 5 }, { stateModel: { name: "Alice" } }, ), ).toBe(false); }); }); describe("dynamic path references in comparison", () => { it("eq with $state reference on right", () => { expect( evaluateVisibility( { $state: "/count", eq: { $state: "/limit" } }, { stateModel: { count: 5, limit: 5 } }, ), ).toBe(true); }); it("lt with $state reference on right", () => { expect( evaluateVisibility( { $state: "/count", lt: { $state: "/limit" } }, { stateModel: { count: 3, limit: 5 } }, ), ).toBe(true); }); }); describe("array (implicit AND)", () => { it("returns true when all conditions are true", () => { expect( evaluateVisibility( [{ $state: "/isAdmin" }, { $state: "/tab", eq: "settings" }], { stateModel: { isAdmin: true, tab: "settings" } }, ), ).toBe(true); }); it("returns false when one condition is false", () => { expect( evaluateVisibility( [{ $state: "/isAdmin" }, { $state: "/tab", eq: "settings" }], { stateModel: { isAdmin: false, tab: "settings" } }, ), ).toBe(false); }); it("returns false when all conditions are false", () => { expect( evaluateVisibility( [{ $state: "/isAdmin" }, { $state: "/tab", eq: "settings" }], { stateModel: { isAdmin: false, tab: "home" } }, ), ).toBe(false); }); }); describe("$and condition (explicit AND)", () => { it("returns true when all children are true", () => { expect( evaluateVisibility( { $and: [{ $state: "/isAdmin" }, { $state: "/tab", eq: "settings" }], }, { stateModel: { isAdmin: true, tab: "settings" } }, ), ).toBe(true); }); it("returns false when one child is false", () => { expect( evaluateVisibility( { $and: [{ $state: "/isAdmin" }, { $state: "/tab", eq: "settings" }], }, { stateModel: { isAdmin: false, tab: "settings" } }, ), ).toBe(false); }); it("returns false when all children are false", () => { expect( evaluateVisibility( { $and: [{ $state: "/isAdmin" }, { $state: "/tab", eq: "settings" }], }, { stateModel: { isAdmin: false, tab: "home" } }, ), ).toBe(false); }); it("supports nested $or inside $and", () => { // AND( OR(isAdmin, isModerator), tab=settings ) expect( evaluateVisibility( { $and: [ { $or: [{ $state: "/isAdmin" }, { $state: "/isModerator" }] }, { $state: "/tab", eq: "settings" }, ], }, { stateModel: { isAdmin: false, isModerator: true, tab: "settings", }, }, ), ).toBe(true); expect( evaluateVisibility( { $and: [ { $or: [{ $state: "/isAdmin" }, { $state: "/isModerator" }] }, { $state: "/tab", eq: "settings" }, ], }, { stateModel: { isAdmin: false, isModerator: false, tab: "settings", }, }, ), ).toBe(false); }); it("supports booleans inside $and", () => { expect( evaluateVisibility( { $and: [true, { $state: "/ok" }] }, { stateModel: { ok: true } }, ), ).toBe(true); expect( evaluateVisibility( { $and: [false, { $state: "/ok" }] }, { stateModel: { ok: true } }, ), ).toBe(false); }); }); describe("$or condition", () => { it("returns true when at least one child is true", () => { expect( evaluateVisibility( { $or: [{ $state: "/isAdmin" }, { $state: "/isModerator" }] }, { stateModel: { isAdmin: false, isModerator: true } }, ), ).toBe(true); }); it("returns true when all children are true", () => { expect( evaluateVisibility( { $or: [{ $state: "/isAdmin" }, { $state: "/isModerator" }] }, { stateModel: { isAdmin: true, isModerator: true } }, ), ).toBe(true); }); it("returns false when all children are false", () => { expect( evaluateVisibility( { $or: [{ $state: "/isAdmin" }, { $state: "/isModerator" }] }, { stateModel: { isAdmin: false, isModerator: false } }, ), ).toBe(false); }); it("supports nested arrays (AND inside OR)", () => { // OR( AND(isAdmin, tab=settings), isSuperUser ) expect( evaluateVisibility( { $or: [ [{ $state: "/isAdmin" }, { $state: "/tab", eq: "settings" }], { $state: "/isSuperUser" }, ], }, { stateModel: { isAdmin: true, tab: "settings", isSuperUser: false }, }, ), ).toBe(true); expect( evaluateVisibility( { $or: [ [{ $state: "/isAdmin" }, { $state: "/tab", eq: "settings" }], { $state: "/isSuperUser" }, ], }, { stateModel: { isAdmin: false, tab: "settings", isSuperUser: false }, }, ), ).toBe(false); }); it("supports booleans inside $or", () => { expect( evaluateVisibility( { $or: [false, { $state: "/ok" }] }, { stateModel: { ok: true } }, ), ).toBe(true); expect( evaluateVisibility({ $or: [false, false] }, { stateModel: {} }), ).toBe(false); }); }); describe("$item conditions", () => { it("$item truthiness check", () => { expect( evaluateVisibility( { $item: "active" }, { stateModel: {}, repeatItem: { active: true } }, ), ).toBe(true); }); it("$item falsy check", () => { expect( evaluateVisibility( { $item: "active" }, { stateModel: {}, repeatItem: { active: false } }, ), ).toBe(false); }); it("$item equality check", () => { expect( evaluateVisibility( { $item: "status", eq: "done" }, { stateModel: {}, repeatItem: { status: "done" } }, ), ).toBe(true); }); it("$item equality check fails", () => { expect( evaluateVisibility( { $item: "status", eq: "done" }, { stateModel: {}, repeatItem: { status: "pending" } }, ), ).toBe(false); }); it("$item root reference", () => { expect( evaluateVisibility( { $item: "", eq: "hello" }, { stateModel: {}, repeatItem: "hello" }, ), ).toBe(true); }); it("$item with not", () => { expect( evaluateVisibility( { $item: "active", not: true }, { stateModel: {}, repeatItem: { active: true } }, ), ).toBe(false); }); it("$item returns false when no repeat scope", () => { expect(evaluateVisibility({ $item: "x" }, { stateModel: {} })).toBe( false, ); }); }); describe("$index conditions", () => { it("$index equality check", () => { expect( evaluateVisibility( { $index: true, eq: 0 }, { stateModel: {}, repeatIndex: 0 }, ), ).toBe(true); }); it("$index equality check fails", () => { expect( evaluateVisibility( { $index: true, eq: 0 }, { stateModel: {}, repeatIndex: 1 }, ), ).toBe(false); }); it("$index gt check", () => { expect( evaluateVisibility( { $index: true, gt: 2 }, { stateModel: {}, repeatIndex: 5 }, ), ).toBe(true); }); it("$index truthiness", () => { expect( evaluateVisibility( { $index: true }, { stateModel: {}, repeatIndex: 3 }, ), ).toBe(true); }); it("$index zero is falsy", () => { expect( evaluateVisibility( { $index: true }, { stateModel: {}, repeatIndex: 0 }, ), ).toBe(false); }); it("$index with not", () => { expect( evaluateVisibility( { $index: true, eq: 0, not: true }, { stateModel: {}, repeatIndex: 1 }, ), ).toBe(true); }); }); }); describe("visibility helper", () => { it("always is true", () => { expect(visibility.always).toBe(true); }); it("never is false", () => { expect(visibility.never).toBe(false); }); it("when creates a $state condition", () => { expect(visibility.when("/user/isAdmin")).toEqual({ $state: "/user/isAdmin", }); }); it("unless creates a negated $state condition", () => { expect(visibility.unless("/form/hasErrors")).toEqual({ $state: "/form/hasErrors", not: true, }); }); it("eq creates an equality condition", () => { expect(visibility.eq("/tab", "home")).toEqual({ $state: "/tab", eq: "home", }); }); it("neq creates an inequality condition", () => { expect(visibility.neq("/role", "guest")).toEqual({ $state: "/role", neq: "guest", }); }); it("gt creates a greater-than condition", () => { expect(visibility.gt("/count", 5)).toEqual({ $state: "/count", gt: 5, }); }); it("gte creates a gte condition", () => { expect(visibility.gte("/count", 5)).toEqual({ $state: "/count", gte: 5, }); }); it("lt creates a less-than condition", () => { expect(visibility.lt("/count", 5)).toEqual({ $state: "/count", lt: 5, }); }); it("lte creates a lte condition", () => { expect(visibility.lte("/count", 5)).toEqual({ $state: "/count", lte: 5, }); }); it("and returns an $and wrapper", () => { const result = visibility.and( visibility.when("/isAdmin"), visibility.eq("/tab", "home"), ); expect(result).toEqual({ $and: [{ $state: "/isAdmin" }, { $state: "/tab", eq: "home" }], }); }); it("or returns an $or wrapper", () => { const result = visibility.or( visibility.when("/isAdmin"), visibility.when("/isModerator"), ); expect(result).toEqual({ $or: [{ $state: "/isAdmin" }, { $state: "/isModerator" }], }); }); }); ================================================ FILE: packages/core/src/visibility.ts ================================================ import { z } from "zod"; import type { VisibilityCondition, StateCondition, ItemCondition, IndexCondition, SingleCondition, AndCondition, OrCondition, StateModel, } from "./types"; import { getByPath } from "./types"; // ============================================================================= // Schemas // ============================================================================= /** * Schema for a single state condition. */ const numericOrStateRef = z.union([ z.number(), z.object({ $state: z.string() }), ]); const comparisonOps = { eq: z.unknown().optional(), neq: z.unknown().optional(), gt: numericOrStateRef.optional(), gte: numericOrStateRef.optional(), lt: numericOrStateRef.optional(), lte: numericOrStateRef.optional(), not: z.literal(true).optional(), }; const StateConditionSchema = z.object({ $state: z.string(), ...comparisonOps, }); const ItemConditionSchema = z.object({ $item: z.string(), ...comparisonOps, }); const IndexConditionSchema = z.object({ $index: z.literal(true), ...comparisonOps, }); const SingleConditionSchema = z.union([ StateConditionSchema, ItemConditionSchema, IndexConditionSchema, ]); /** * Visibility condition schema. * * Lazy because `OrCondition` can recursively contain `VisibilityCondition`. */ export const VisibilityConditionSchema: z.ZodType = z.lazy( () => z.union([ z.boolean(), SingleConditionSchema, z.array(SingleConditionSchema), z.object({ $and: z.array(VisibilityConditionSchema) }), z.object({ $or: z.array(VisibilityConditionSchema) }), ]), ); // ============================================================================= // Context // ============================================================================= /** * Context for evaluating visibility conditions. * * `repeatItem` and `repeatIndex` are only present inside a `repeat` scope * and enable `$item` / `$index` conditions. */ export interface VisibilityContext { stateModel: StateModel; /** The current repeat item (set inside a repeat scope). */ repeatItem?: unknown; /** The current repeat array index (set inside a repeat scope). */ repeatIndex?: number; } // ============================================================================= // Evaluation // ============================================================================= /** * Resolve a comparison value. If it's a `{ $state }` reference, look it up; * otherwise return the literal. */ function resolveComparisonValue( value: unknown, ctx: VisibilityContext, ): unknown { if (typeof value === "object" && value !== null) { if ( "$state" in value && typeof (value as Record).$state === "string" ) { return getByPath(ctx.stateModel, (value as { $state: string }).$state); } } return value; } /** * Type guards for condition sources. */ function isItemCondition(cond: SingleCondition): cond is ItemCondition { return "$item" in cond; } function isIndexCondition(cond: SingleCondition): cond is IndexCondition { return "$index" in cond; } /** * Resolve the left-hand-side value of a condition based on its source. */ function resolveConditionValue( cond: SingleCondition, ctx: VisibilityContext, ): unknown { if (isIndexCondition(cond)) { return ctx.repeatIndex; } if (isItemCondition(cond)) { if (ctx.repeatItem === undefined) return undefined; return cond.$item === "" ? ctx.repeatItem : getByPath(ctx.repeatItem, cond.$item); } // StateCondition return getByPath(ctx.stateModel, (cond as StateCondition).$state); } /** * Evaluate a single condition against the context. * * When `not` is `true`, the final result is inverted — this applies to * whichever operator is present (or to the truthiness check if no operator * is given). For example: * - `{ $state: "/x", not: true }` → `!Boolean(value)` * - `{ $state: "/x", gt: 5, not: true }` → `!(value > 5)` */ function evaluateCondition( cond: SingleCondition, ctx: VisibilityContext, ): boolean { const value = resolveConditionValue(cond, ctx); let result: boolean; // Equality if (cond.eq !== undefined) { const rhs = resolveComparisonValue(cond.eq, ctx); result = value === rhs; } // Inequality else if (cond.neq !== undefined) { const rhs = resolveComparisonValue(cond.neq, ctx); result = value !== rhs; } // Greater than else if (cond.gt !== undefined) { const rhs = resolveComparisonValue(cond.gt, ctx); result = typeof value === "number" && typeof rhs === "number" ? value > rhs : false; } // Greater than or equal else if (cond.gte !== undefined) { const rhs = resolveComparisonValue(cond.gte, ctx); result = typeof value === "number" && typeof rhs === "number" ? value >= rhs : false; } // Less than else if (cond.lt !== undefined) { const rhs = resolveComparisonValue(cond.lt, ctx); result = typeof value === "number" && typeof rhs === "number" ? value < rhs : false; } // Less than or equal else if (cond.lte !== undefined) { const rhs = resolveComparisonValue(cond.lte, ctx); result = typeof value === "number" && typeof rhs === "number" ? value <= rhs : false; } // Truthiness (no operator) else { result = Boolean(value); } // `not` inverts the result of any condition return cond.not === true ? !result : result; } /** * Type guard for AndCondition */ function isAndCondition( condition: VisibilityCondition, ): condition is AndCondition { return ( typeof condition === "object" && condition !== null && !Array.isArray(condition) && "$and" in condition ); } /** * Type guard for OrCondition */ function isOrCondition( condition: VisibilityCondition, ): condition is OrCondition { return ( typeof condition === "object" && condition !== null && !Array.isArray(condition) && "$or" in condition ); } /** * Evaluate a visibility condition. * * - `undefined` → visible * - `boolean` → that value * - `SingleCondition` → evaluate single condition * - `SingleCondition[]` → implicit AND (all must be true) * - `AndCondition` → `{ $and: [...] }`, explicit AND * - `OrCondition` → `{ $or: [...] }`, at least one must be true */ export function evaluateVisibility( condition: VisibilityCondition | undefined, ctx: VisibilityContext, ): boolean { // No condition = visible if (condition === undefined) { return true; } // Boolean literal if (typeof condition === "boolean") { return condition; } // Array = implicit AND if (Array.isArray(condition)) { return condition.every((c) => evaluateCondition(c, ctx)); } // Explicit AND condition if (isAndCondition(condition)) { return condition.$and.every((child) => evaluateVisibility(child, ctx)); } // OR condition if (isOrCondition(condition)) { return condition.$or.some((child) => evaluateVisibility(child, ctx)); } // Single condition return evaluateCondition(condition, ctx); } // ============================================================================= // Helpers // ============================================================================= /** * Helper to create visibility conditions. */ export const visibility = { /** Always visible */ always: true as const, /** Never visible */ never: false as const, /** Visible when state path is truthy */ when: (path: string): StateCondition => ({ $state: path }), /** Visible when state path is falsy */ unless: (path: string): StateCondition => ({ $state: path, not: true }), /** Equality check */ eq: (path: string, value: unknown): StateCondition => ({ $state: path, eq: value, }), /** Not equal check */ neq: (path: string, value: unknown): StateCondition => ({ $state: path, neq: value, }), /** Greater than */ gt: (path: string, value: number | { $state: string }): StateCondition => ({ $state: path, gt: value, }), /** Greater than or equal */ gte: (path: string, value: number | { $state: string }): StateCondition => ({ $state: path, gte: value, }), /** Less than */ lt: (path: string, value: number | { $state: string }): StateCondition => ({ $state: path, lt: value, }), /** Less than or equal */ lte: (path: string, value: number | { $state: string }): StateCondition => ({ $state: path, lte: value, }), /** AND multiple conditions */ and: (...conditions: VisibilityCondition[]): AndCondition => ({ $and: conditions, }), /** OR multiple conditions */ or: (...conditions: VisibilityCondition[]): OrCondition => ({ $or: conditions, }), }; ================================================ FILE: packages/core/tsconfig.json ================================================ { "extends": "@internal/typescript-config/react-library.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/core/tsup.config.ts ================================================ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts", "src/store-utils.ts"], format: ["cjs", "esm"], dts: true, sourcemap: true, clean: true, external: ["zod"], }); ================================================ FILE: packages/eslint-config/README.md ================================================ # `@turbo/eslint-config` Collection of internal eslint configurations. ================================================ FILE: packages/eslint-config/base.js ================================================ import js from "@eslint/js"; import eslintConfigPrettier from "eslint-config-prettier"; import turboPlugin from "eslint-plugin-turbo"; import tseslint from "typescript-eslint"; import onlyWarn from "eslint-plugin-only-warn"; /** * A shared ESLint configuration for the repository. * * @type {import("eslint").Linter.Config[]} * */ export const config = [ js.configs.recommended, eslintConfigPrettier, ...tseslint.configs.recommended, { plugins: { turbo: turboPlugin, }, rules: { "turbo/no-undeclared-env-vars": "warn", }, }, { plugins: { onlyWarn, }, }, { ignores: ["dist/**"], }, ]; ================================================ FILE: packages/eslint-config/next.js ================================================ import js from "@eslint/js"; import { globalIgnores } from "eslint/config"; import eslintConfigPrettier from "eslint-config-prettier"; import tseslint from "typescript-eslint"; import pluginReactHooks from "eslint-plugin-react-hooks"; import pluginReact from "eslint-plugin-react"; import globals from "globals"; import pluginNext from "@next/eslint-plugin-next"; import { config as baseConfig } from "./base.js"; /** * A custom ESLint configuration for libraries that use Next.js. * * @type {import("eslint").Linter.Config[]} * */ export const nextJsConfig = [ ...baseConfig, js.configs.recommended, eslintConfigPrettier, ...tseslint.configs.recommended, globalIgnores([ // Default ignores of eslint-config-next: ".next/**", "out/**", "build/**", "next-env.d.ts", ]), { ...pluginReact.configs.flat.recommended, languageOptions: { ...pluginReact.configs.flat.recommended.languageOptions, globals: { ...globals.serviceworker, }, }, }, { plugins: { "@next/next": pluginNext, }, rules: { ...pluginNext.configs.recommended.rules, ...pluginNext.configs["core-web-vitals"].rules, }, }, { plugins: { "react-hooks": pluginReactHooks, }, settings: { react: { version: "detect" } }, rules: { ...pluginReactHooks.configs.recommended.rules, // React scope no longer necessary with new JSX transform. "react/react-in-jsx-scope": "off", }, }, ]; ================================================ FILE: packages/eslint-config/package.json ================================================ { "name": "@internal/eslint-config", "version": "0.0.0", "type": "module", "private": true, "exports": { "./base": "./base.js", "./next-js": "./next.js", "./react-internal": "./react-internal.js" }, "devDependencies": { "@eslint/js": "^9.39.1", "@next/eslint-plugin-next": "^15.5.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.1", "eslint-plugin-only-warn": "^1.1.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-turbo": "^2.7.1", "globals": "^16.5.0", "typescript": "^5.9.2", "typescript-eslint": "^8.50.0" } } ================================================ FILE: packages/eslint-config/react-internal.js ================================================ import js from "@eslint/js"; import eslintConfigPrettier from "eslint-config-prettier"; import tseslint from "typescript-eslint"; import pluginReactHooks from "eslint-plugin-react-hooks"; import pluginReact from "eslint-plugin-react"; import globals from "globals"; import { config as baseConfig } from "./base.js"; /** * A custom ESLint configuration for libraries that use React. * * @type {import("eslint").Linter.Config[]} */ export const config = [ ...baseConfig, js.configs.recommended, eslintConfigPrettier, ...tseslint.configs.recommended, pluginReact.configs.flat.recommended, { languageOptions: { ...pluginReact.configs.flat.recommended.languageOptions, globals: { ...globals.serviceworker, ...globals.browser, }, }, }, { plugins: { "react-hooks": pluginReactHooks, }, settings: { react: { version: "detect" } }, rules: { ...pluginReactHooks.configs.recommended.rules, // React scope no longer necessary with new JSX transform. "react/react-in-jsx-scope": "off", }, }, ]; ================================================ FILE: packages/image/CHANGELOG.md ================================================ # @json-render/image ## 0.14.1 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 ## 0.14.0 ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 ## 0.13.0 ### Patch Changes - Updated dependencies [5b32de8] - @json-render/core@0.13.0 ## 0.12.1 ### Patch Changes - Updated dependencies [54a1ecf] - @json-render/core@0.12.1 ## 0.12.0 ### Patch Changes - Updated dependencies [63c339b] - @json-render/core@0.12.0 ## 0.11.0 ### Minor Changes - 3f1e71e: Image renderer: generate SVG and PNG from JSON specs. ### New: `@json-render/image` Package Server-side image renderer powered by Satori. Turns the same `{ root, elements }` spec format into SVG or PNG output for OG images, social cards, and banners. - `renderToSvg(spec, options)` — render spec to SVG string - `renderToPng(spec, options)` — render spec to PNG buffer (requires `@resvg/resvg-js`) - 9 standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer - `standardComponentDefinitions` catalog for AI prompt generation - Server-safe import path: `@json-render/image/server` - Sub-path exports: `/render`, `/catalog`, `/server` ### Patch Changes - Updated dependencies [3f1e71e] - @json-render/core@0.11.0 ================================================ FILE: packages/image/README.md ================================================ # @json-render/image Image renderer for `@json-render/core`. Generate SVG and PNG images from JSON specs using [Satori](https://github.com/vercel/satori). ## Install ```bash npm install @json-render/core @json-render/image ``` For PNG output, also install the optional peer dependency: ```bash npm install @resvg/resvg-js ``` ## Quick Start ### Render a spec to SVG ```typescript import { renderToSvg } from "@json-render/image/render"; import type { Spec } from "@json-render/core"; const spec: Spec = { root: "frame", elements: { frame: { type: "Frame", props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" }, children: ["heading", "subtitle"], }, heading: { type: "Heading", props: { text: "Hello World", level: "h1", color: "#ffffff" }, children: [], }, subtitle: { type: "Text", props: { text: "Generated from JSON", fontSize: 24, color: "#a0a0b0" }, children: [], }, }, }; const svg = await renderToSvg(spec, { fonts: [ { name: "Inter", data: await fetch("https://example.com/Inter-Regular.ttf").then((r) => r.arrayBuffer() ), weight: 400, style: "normal", }, ], }); ``` ### Render to PNG ```typescript import { renderToPng } from "@json-render/image/render"; const png = await renderToPng(spec, { fonts: [ { name: "Inter", data: await readFile("./Inter-Regular.ttf"), weight: 400, style: "normal", }, ], }); // Write to file await writeFile("output.png", png); ``` ### With a custom catalog ```typescript import { defineCatalog } from "@json-render/core"; import { schema, renderToSvg } from "@json-render/image"; import { standardComponentDefinitions } from "@json-render/image/catalog"; import { z } from "zod"; const catalog = defineCatalog(schema, { components: { ...standardComponentDefinitions, Badge: { props: z.object({ label: z.string(), color: z.string().nullable(), }), slots: [], description: "A colored badge label", }, }, }); ``` ## Standard Components ### Root | Component | Description | |-----------|-------------| | `Frame` | Root image container. Defines width, height, and background. Must be the root element. | ### Layout | Component | Description | |-----------|-------------| | `Box` | Generic container with padding, margin, background, border, and flex alignment. | | `Row` | Horizontal flex layout with gap, align, justify. | | `Column` | Vertical flex layout with gap, align, justify. | ### Content | Component | Description | |-----------|-------------| | `Heading` | h1-h4 heading text with color and alignment. | | `Text` | Body text with fontSize, color, weight, style, and alignment. | | `Image` | Image from a URL with width, height, and borderRadius. | ### Decorative | Component | Description | |-----------|-------------| | `Divider` | Horizontal line separator. | | `Spacer` | Empty vertical space. | ## Server-Side APIs ```typescript import { renderToSvg, renderToPng } from "@json-render/image/render"; // Render to an SVG string const svg = await renderToSvg(spec, { fonts }); // Render to a PNG buffer (requires @resvg/resvg-js) const png = await renderToPng(spec, { fonts }); ``` ### Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `fonts` | `SatoriOptions['fonts']` | `[]` | Font data for text rendering | | `width` | `number` | Frame prop | Override image width | | `height` | `number` | Frame prop | Override image height | | `registry` | `Record` | `{}` | Custom component overrides | | `includeStandard` | `boolean` | `true` | Include standard components | | `state` | `Record` | `{}` | Initial state values | ## Server-Safe Import Import schema and catalog definitions without pulling in React or Satori: ```typescript import { schema, standardComponentDefinitions } from "@json-render/image/server"; ``` ## License Apache-2.0 ================================================ FILE: packages/image/package.json ================================================ { "name": "@json-render/image", "version": "0.14.1", "license": "Apache-2.0", "description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.", "keywords": [ "json", "image", "svg", "png", "og", "satori", "ai", "generative-ui", "llm", "renderer" ], "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", "directory": "packages/image" }, "homepage": "https://json-render.dev", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "publishConfig": { "access": "public" }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" }, "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.mjs", "require": "./dist/server.js" }, "./catalog": { "types": "./dist/catalog.d.ts", "import": "./dist/catalog.mjs", "require": "./dist/catalog.js" }, "./render": { "types": "./dist/render.d.ts", "import": "./dist/render.mjs", "require": "./dist/render.js" } }, "files": [ "dist" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "check-types": "tsc --noEmit", "typecheck": "tsc --noEmit" }, "dependencies": { "@json-render/core": "workspace:*", "satori": "^0.19.2" }, "devDependencies": { "@internal/typescript-config": "workspace:*", "@types/react": "19.2.3", "tsup": "^8.5.1", "typescript": "^5.4.5", "zod": "^4.3.6" }, "peerDependencies": { "@resvg/resvg-js": ">=2.0.0", "react": "^18.0.0 || ^19.0.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { "@resvg/resvg-js": { "optional": true } } } ================================================ FILE: packages/image/src/catalog-types.ts ================================================ import type { Catalog, InferCatalogComponents, InferComponentProps, StateModel, } from "@json-render/core"; export type { StateModel }; export type SetState = ( updater: (prev: Record) => Record, ) => void; export interface ComponentContext< C extends Catalog, K extends keyof InferCatalogComponents, > { props: InferComponentProps; children?: React.ReactNode; emit: (event: string) => void; } export type ComponentFn< C extends Catalog, K extends keyof InferCatalogComponents, > = (ctx: ComponentContext) => React.ReactNode; export type Components = { [K in keyof InferCatalogComponents]: ComponentFn; }; ================================================ FILE: packages/image/src/catalog.ts ================================================ import { z } from "zod"; /** * Standard component definitions for image catalogs. * * These define the available image components with their Zod prop schemas. * All components render to Satori-compatible JSX (HTML-like elements with * inline CSS flexbox styles). */ export const standardComponentDefinitions = { // ========================================================================== // Root // ========================================================================== Frame: { props: z.object({ width: z.number(), height: z.number(), backgroundColor: z.string().nullable(), padding: z.number().nullable(), display: z.enum(["flex", "none"]).nullable(), flexDirection: z.enum(["row", "column"]).nullable(), alignItems: z .enum(["flex-start", "center", "flex-end", "stretch"]) .nullable(), justifyContent: z .enum([ "flex-start", "center", "flex-end", "space-between", "space-around", ]) .nullable(), }), slots: ["default"], description: "Root image container. Defines the output image dimensions and background. Must be the root element.", example: { width: 1200, height: 630, backgroundColor: "#ffffff" }, }, // ========================================================================== // Layout Components // ========================================================================== Box: { props: z.object({ padding: z.number().nullable(), paddingTop: z.number().nullable(), paddingBottom: z.number().nullable(), paddingLeft: z.number().nullable(), paddingRight: z.number().nullable(), margin: z.number().nullable(), backgroundColor: z.string().nullable(), borderWidth: z.number().nullable(), borderColor: z.string().nullable(), borderRadius: z.number().nullable(), flex: z.number().nullable(), width: z.union([z.number(), z.string()]).nullable(), height: z.union([z.number(), z.string()]).nullable(), alignItems: z .enum(["flex-start", "center", "flex-end", "stretch"]) .nullable(), justifyContent: z .enum([ "flex-start", "center", "flex-end", "space-between", "space-around", ]) .nullable(), flexDirection: z.enum(["row", "column"]).nullable(), position: z.enum(["relative", "absolute"]).nullable(), top: z.number().nullable(), left: z.number().nullable(), right: z.number().nullable(), bottom: z.number().nullable(), overflow: z.enum(["visible", "hidden"]).nullable(), }), slots: ["default"], description: "Generic container with padding, margin, background, border, and flex alignment. Supports absolute positioning.", example: { padding: 20, backgroundColor: "#f9f9f9", borderRadius: 8, alignItems: "center", }, }, Row: { props: z.object({ gap: z.number().nullable(), alignItems: z .enum(["flex-start", "center", "flex-end", "stretch"]) .nullable(), justifyContent: z .enum([ "flex-start", "center", "flex-end", "space-between", "space-around", ]) .nullable(), padding: z.number().nullable(), flex: z.number().nullable(), wrap: z.boolean().nullable(), }), slots: ["default"], description: "Horizontal flex layout. Use for placing elements side by side.", example: { gap: 10, alignItems: "center" }, }, Column: { props: z.object({ gap: z.number().nullable(), alignItems: z .enum(["flex-start", "center", "flex-end", "stretch"]) .nullable(), justifyContent: z .enum([ "flex-start", "center", "flex-end", "space-between", "space-around", ]) .nullable(), padding: z.number().nullable(), flex: z.number().nullable(), }), slots: ["default"], description: "Vertical flex layout. Use for stacking elements top to bottom.", example: { gap: 8, padding: 10 }, }, // ========================================================================== // Content Components // ========================================================================== Heading: { props: z.object({ text: z.string(), level: z.enum(["h1", "h2", "h3", "h4"]).nullable(), color: z.string().nullable(), align: z.enum(["left", "center", "right"]).nullable(), letterSpacing: z.union([z.number(), z.string()]).nullable(), lineHeight: z.number().nullable(), }), slots: [], description: "Heading text at various levels. h1 is largest, h4 is smallest.", example: { text: "Hello World", level: "h1", color: "#000000" }, }, Text: { props: z.object({ text: z.string(), fontSize: z.number().nullable(), color: z.string().nullable(), align: z.enum(["left", "center", "right"]).nullable(), fontWeight: z.enum(["normal", "bold"]).nullable(), fontStyle: z.enum(["normal", "italic"]).nullable(), lineHeight: z.number().nullable(), letterSpacing: z.union([z.number(), z.string()]).nullable(), textDecoration: z.enum(["none", "underline", "line-through"]).nullable(), }), slots: [], description: "Body text with configurable size, color, weight, and alignment.", example: { text: "Some content here.", fontSize: 16, color: "#333333" }, }, Image: { props: z.object({ src: z.string(), width: z.number().nullable(), height: z.number().nullable(), borderRadius: z.number().nullable(), objectFit: z.enum(["contain", "cover", "fill", "none"]).nullable(), }), slots: [], description: "Image from a URL. Specify width and/or height to control size. For placeholder images use https://picsum.photos/{width}/{height}?random={n}.", example: { src: "https://picsum.photos/400/300?random=1", width: 400, height: 300, }, }, // ========================================================================== // Decorative Components // ========================================================================== Divider: { props: z.object({ color: z.string().nullable(), thickness: z.number().nullable(), marginTop: z.number().nullable(), marginBottom: z.number().nullable(), }), slots: [], description: "Horizontal line separator between content sections.", example: { color: "#e5e7eb", thickness: 1 }, }, Spacer: { props: z.object({ height: z.number().nullable(), }), slots: [], description: "Empty vertical space between elements.", example: { height: 20 }, }, }; export type StandardComponentDefinitions = typeof standardComponentDefinitions; export type StandardComponentProps< K extends keyof StandardComponentDefinitions, > = StandardComponentDefinitions[K]["props"] extends { _output: infer O } ? O : z.output; ================================================ FILE: packages/image/src/components/index.ts ================================================ export { standardComponents } from "./standard"; ================================================ FILE: packages/image/src/components/standard.tsx ================================================ import React from "react"; import type { ComponentRenderProps, ComponentRegistry } from "../types"; import type { StandardComponentProps } from "../catalog"; const headingSizes: Record = { h1: 48, h2: 36, h3: 28, h4: 22, }; const headingWeights: Record = { h1: 700, h2: 700, h3: 600, h4: 600, }; /** * Satori crashes on explicit `undefined` style values (e.g. `padding: undefined`). * Strip them so only defined properties are passed. */ function cleanStyle(raw: Record): React.CSSProperties { const out: Record = {}; for (const k in raw) { if (raw[k] !== undefined && raw[k] !== null) { out[k] = raw[k]; } } return out as React.CSSProperties; } // ============================================================================= // Root // ============================================================================= function FrameComponent({ element, children, }: ComponentRenderProps>) { const p = element.props; return (
{children}
); } // ============================================================================= // Layout Components // ============================================================================= function BoxComponent({ element, children, }: ComponentRenderProps>) { const p = element.props; return (
{children}
); } function RowComponent({ element, children, }: ComponentRenderProps>) { const p = element.props; return (
{children}
); } function ColumnComponent({ element, children, }: ComponentRenderProps>) { const p = element.props; return (
{children}
); } // ============================================================================= // Content Components // ============================================================================= function HeadingComponent({ element, }: ComponentRenderProps>) { const p = element.props; const level = p.level ?? "h2"; return (
{p.text}
); } function TextComponent({ element, }: ComponentRenderProps>) { const p = element.props; return (
{p.text}
); } function ImageComponent({ element, }: ComponentRenderProps>) { const p = element.props; return ( ); } // ============================================================================= // Decorative Components // ============================================================================= function DividerComponent({ element, }: ComponentRenderProps>) { const p = element.props; return (
); } function SpacerComponent({ element, }: ComponentRenderProps>) { const p = element.props; return
; } // ============================================================================= // Registry // ============================================================================= export const standardComponents: ComponentRegistry = { Frame: FrameComponent, Box: BoxComponent, Row: RowComponent, Column: ColumnComponent, Heading: HeadingComponent, Text: TextComponent, Image: ImageComponent, Divider: DividerComponent, Spacer: SpacerComponent, }; ================================================ FILE: packages/image/src/index.ts ================================================ // Schema export { schema, type ImageSchema, type ImageSpec } from "./schema"; // Core types (re-exported for convenience) export type { Spec } from "@json-render/core"; // Catalog-aware types export type { SetState, StateModel, ComponentContext, ComponentFn, Components, } from "./catalog-types"; // Renderer types export type { ComponentRenderProps, ComponentRenderer, ComponentRegistry, } from "./types"; // Standard components export { standardComponents } from "./components"; // Server-side render functions export { renderToSvg, renderToPng, type RenderOptions } from "./render"; // Catalog definitions export { standardComponentDefinitions, type StandardComponentDefinitions, type StandardComponentProps, } from "./catalog"; ================================================ FILE: packages/image/src/render.test.tsx ================================================ import { describe, it, expect } from "vitest"; import type { Spec } from "@json-render/core"; import { renderToSvg, renderToPng } from "./render"; const minimalSpec: Spec = { root: "frame", elements: { frame: { type: "Frame", props: { width: 400, height: 200, backgroundColor: "#ffffff", }, children: ["box"], }, box: { type: "Box", props: { width: 100, height: 50, backgroundColor: "#ff0000", borderRadius: 8, }, children: [], }, }, }; describe("renderToSvg", () => { it("produces a valid SVG string", async () => { const svg = await renderToSvg(minimalSpec); expect(svg).toContain(""); }); it("respects width/height from Frame props", async () => { const svg = await renderToSvg(minimalSpec); expect(svg).toContain('width="400"'); expect(svg).toContain('height="200"'); }); it("uses explicit width/height options over Frame props", async () => { const svg = await renderToSvg(minimalSpec, { width: 800, height: 600 }); expect(svg).toContain('width="800"'); expect(svg).toContain('height="600"'); }); it("falls back to defaults when Frame has no dimensions", async () => { const spec: Spec = { root: "frame", elements: { frame: { type: "Frame", props: { backgroundColor: "#000" }, children: [], }, }, }; const svg = await renderToSvg(spec); expect(svg).toContain('width="1200"'); expect(svg).toContain('height="630"'); }); it("gracefully skips unknown component types", async () => { const spec: Spec = { root: "frame", elements: { frame: { type: "Frame", props: { width: 400, height: 200 }, children: ["unknown"], }, unknown: { type: "NonExistent", props: {}, children: [], }, }, }; const svg = await renderToSvg(spec); expect(svg).toContain(" { const spec: Spec = { root: "frame", elements: { frame: { type: "Frame", props: { width: 400, height: 200 }, children: [], }, }, }; const svg = await renderToSvg(spec, { includeStandard: false }); expect(svg).toContain(" { it("produces a buffer with content", async () => { const png = await renderToPng(minimalSpec); expect(png.length).toBeGreaterThan(0); expect(png.byteLength).toBeGreaterThan(0); }); it("starts with PNG magic bytes", async () => { const png = await renderToPng(minimalSpec); expect(png[0]).toBe(0x89); expect(png[1]).toBe(0x50); // P expect(png[2]).toBe(0x4e); // N expect(png[3]).toBe(0x47); // G }); }); ================================================ FILE: packages/image/src/render.tsx ================================================ import React from "react"; import satori, { type SatoriOptions } from "satori"; import type { Spec, UIElement } from "@json-render/core"; import { resolveElementProps, evaluateVisibility, getByPath, type PropResolutionContext, } from "@json-render/core"; import { standardComponents } from "./components/standard"; import type { ComponentRegistry } from "./types"; export { standardComponents }; export interface RenderOptions { registry?: ComponentRegistry; includeStandard?: boolean; state?: Record; fonts?: SatoriOptions["fonts"]; /** Override the Frame width. When omitted, uses the Frame component's width prop. */ width?: number; /** Override the Frame height. When omitted, uses the Frame component's height prop. */ height?: number; } const noopEmit = () => {}; function renderElement( elementKey: string, spec: Spec, registry: ComponentRegistry, stateModel: Record, repeatItem?: unknown, repeatIndex?: number, repeatBasePath?: string, ): React.ReactElement | null { const element = spec.elements[elementKey]; if (!element) return null; const ctx: PropResolutionContext = { stateModel, repeatItem, repeatIndex, repeatBasePath, }; if (element.visible !== undefined) { if (!evaluateVisibility(element.visible, ctx)) { return null; } } const resolvedProps = resolveElementProps( element.props as Record, ctx, ); const resolvedElement: UIElement = { ...element, props: resolvedProps }; const Component = registry[resolvedElement.type]; if (!Component) return null; if (resolvedElement.repeat) { const items = (getByPath(stateModel, resolvedElement.repeat.statePath) as | unknown[] | undefined) ?? []; const fragments = items.map((item, index) => { const key = resolvedElement.repeat!.key && typeof item === "object" && item !== null ? String( (item as Record)[resolvedElement.repeat!.key!] ?? index, ) : String(index); const childPath = `${resolvedElement.repeat!.statePath}/${index}`; const children = resolvedElement.children?.map((childKey) => renderElement( childKey, spec, registry, stateModel, item, index, childPath, ), ); return ( {children} ); }); return <>{fragments}; } const children = resolvedElement.children?.map((childKey) => renderElement( childKey, spec, registry, stateModel, repeatItem, repeatIndex, repeatBasePath, ), ); return ( {children && children.length > 0 ? children : undefined} ); } interface ImageDimensions { width: number; height: number; } function getDimensions( spec: Spec, options: RenderOptions = {}, ): ImageDimensions { if (options.width && options.height) { return { width: options.width, height: options.height }; } const rootElement = spec.elements[spec.root]; const props = rootElement?.props as Record | undefined; return { width: options.width ?? (props?.width as number) ?? 1200, height: options.height ?? (props?.height as number) ?? 630, }; } function buildTree( spec: Spec, options: RenderOptions = {}, ): React.ReactElement { const { registry: customRegistry, includeStandard = true, state = {}, } = options; const mergedState: Record = { ...spec.state, ...state, }; const registry: ComponentRegistry = { ...(includeStandard ? standardComponents : {}), ...customRegistry, }; const root = renderElement(spec.root, spec, registry, mergedState); return root ?? <>; } /** * Render a json-render spec to an SVG string. * * Uses Satori to convert the spec's component tree into SVG. * No additional dependencies are needed beyond satori. */ export async function renderToSvg( spec: Spec, options: RenderOptions = {}, ): Promise { const tree = buildTree(spec, options); const { width, height } = getDimensions(spec, options); return satori(tree as React.ReactNode, { width, height, fonts: options.fonts ?? [], }); } /** * Render a json-render spec to a PNG buffer. * * Requires `@resvg/resvg-js` to be installed as a peer dependency. * The SVG is first generated via Satori, then rasterized to PNG. */ export async function renderToPng( spec: Spec, options: RenderOptions = {}, ): Promise { const svg = await renderToSvg(spec, options); let Resvg: typeof import("@resvg/resvg-js").Resvg; try { const mod = await import("@resvg/resvg-js"); Resvg = mod.Resvg; } catch { throw new Error( "@resvg/resvg-js is required for PNG output. Install it with: npm install @resvg/resvg-js", ); } const resvg = new Resvg(svg); const pngData = resvg.render(); return pngData.asPng(); } ================================================ FILE: packages/image/src/schema.ts ================================================ import { defineSchema } from "@json-render/core"; /** * The schema for @json-render/image * * Defines: * - Spec: A flat tree of elements with keys, types, props, and children references * - Catalog: Components with props schemas * * Reuses the same { root, elements } spec format as the React and React PDF renderers. */ export const schema = defineSchema( (s) => ({ spec: s.object({ root: s.string(), elements: s.record( s.object({ type: s.ref("catalog.components"), props: s.propsOf("catalog.components"), children: s.array(s.string()), visible: s.any(), }), ), }), catalog: s.object({ components: s.map({ props: s.zod(), slots: s.array(s.string()), description: s.string(), example: s.any(), }), }), }), { defaultRules: [ "The root element MUST be a Frame component. It defines the image dimensions (width, height) and background.", "Frame width and height determine the output image size. Common sizes: 1200x630 (OG image), 1080x1080 (social square), 1920x1080 (banner).", "Use Row for horizontal layouts and Column for vertical layouts. Both support gap, align, and justify props.", "All text content must use Heading or Text components. Raw strings are not supported.", "Image src must be a fully qualified URL. For placeholder images, use https://picsum.photos/{width}/{height}?random={n}.", "Satori renders a subset of CSS: flexbox layout, borders, backgrounds, text styling. Absolute positioning is supported via position/top/left/right/bottom.", "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist.", ], }, ); export type ImageSchema = typeof schema; export type ImageSpec = typeof schema extends { createCatalog: (catalog: TCatalog) => { _specType: infer S }; } ? S : never; ================================================ FILE: packages/image/src/server.ts ================================================ // Server-safe entry point: schema and catalog definitions only. // Does not import React or Satori. export { schema, type ImageSchema, type ImageSpec } from "./schema"; export { standardComponentDefinitions, type StandardComponentDefinitions, type StandardComponentProps, } from "./catalog"; export type { Spec } from "@json-render/core"; export type { SetState, StateModel, ComponentContext, ComponentFn, Components, } from "./catalog-types"; ================================================ FILE: packages/image/src/types.ts ================================================ import type { ComponentType, ReactNode } from "react"; import type { UIElement } from "@json-render/core"; export interface ComponentRenderProps

> { element: UIElement; children?: ReactNode; emit: (event: string) => void; } export type ComponentRenderer

> = ComponentType< ComponentRenderProps

>; export type ComponentRegistry = Record>; ================================================ FILE: packages/image/tsconfig.json ================================================ { "extends": "@internal/typescript-config/react-library.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/image/tsup.config.ts ================================================ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts", "src/server.ts", "src/catalog.ts", "src/render.tsx"], format: ["cjs", "esm"], dts: true, sourcemap: true, clean: true, external: [ "@json-render/core", "satori", "@resvg/resvg-js", "zod", "react", "react/jsx-runtime", ], }); ================================================ FILE: packages/jotai/CHANGELOG.md ================================================ # @json-render/jotai ## 0.14.1 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 ## 0.14.0 ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 ## 0.13.0 ### Patch Changes - Updated dependencies [5b32de8] - @json-render/core@0.13.0 ## 0.12.1 ### Patch Changes - Updated dependencies [54a1ecf] - @json-render/core@0.12.1 ## 0.12.0 ### Patch Changes - Updated dependencies [63c339b] - @json-render/core@0.12.0 ## 0.11.0 ### Patch Changes - Updated dependencies [3f1e71e] - @json-render/core@0.11.0 ## 0.10.0 ### Patch Changes - Updated dependencies [9cef4e9] - @json-render/core@0.10.0 ## 0.9.1 ### Patch Changes - @json-render/core@0.9.1 ## 0.9.0 ### Minor Changes - 1d755c1: External state store, store adapters, and bug fixes. ### New: External State Store The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode. - Added `StateStore` interface and `createStateStore()` factory to `@json-render/core` - `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop for controlled mode - When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored) - When `store` is omitted, everything works exactly as before (fully backward compatible) - Applied across all platform packages: react, react-native, react-pdf - Store utilities (`createStoreAdapter`, `immutableSetByPath`, `flattenToPointers`) available via `@json-render/core/store-utils` for building custom adapters ### New: Store Adapter Packages - `@json-render/zustand` — Zustand adapter for `StateStore` - `@json-render/redux` — Redux / Redux Toolkit adapter for `StateStore` - `@json-render/jotai` — Jotai adapter for `StateStore` ### Changed: `onStateChange` signature updated (breaking) The `onStateChange` callback now receives a single array of changed entries instead of being called once per path: ```ts // Before onStateChange?: (path: string, value: unknown) => void // After onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void ``` ### Fixed - Fix schema import to use server-safe `@json-render/react/schema` subpath, avoiding `createContext` crashes in Next.js App Router API routes - Fix chaining actions in `@json-render/react`, `@json-render/react-native`, and `@json-render/react-pdf` - Fix safely resolving inner type for Zod arrays in core schema ### Patch Changes - Updated dependencies [1d755c1] - @json-render/core@0.9.0 ================================================ FILE: packages/jotai/README.md ================================================ # @json-render/jotai Jotai adapter for json-render's `StateStore` interface. Wire a Jotai atom as the state backend for json-render. ## Installation ```bash npm install @json-render/jotai @json-render/core @json-render/react jotai ``` ## Usage ```ts import { atom } from "jotai"; import { jotaiStateStore } from "@json-render/jotai"; import { StateProvider } from "@json-render/react"; // 1. Create an atom that holds the json-render state const uiAtom = atom>({ count: 0 }); // 2. Create the json-render StateStore adapter const store = jotaiStateStore({ atom: uiAtom }); // 3. Use it {/* json-render reads/writes go through Jotai */} ``` ### With a shared Jotai store If your app already uses a Jotai `` with a custom store, pass it so both json-render and your components share the same state: ```ts import { atom, createStore } from "jotai"; import { Provider as JotaiProvider } from "jotai/react"; import { jotaiStateStore } from "@json-render/jotai"; import { StateProvider } from "@json-render/react"; const jStore = createStore(); const uiAtom = atom>({ count: 0 }); const store = jotaiStateStore({ atom: uiAtom, store: jStore }); {/* Both json-render and useAtom() see the same state */} ``` ## API ### `jotaiStateStore(options)` Creates a `StateStore` backed by a Jotai atom. #### Options | Option | Type | Required | Description | |--------|------|----------|-------------| | `atom` | `WritableAtom` | Yes | A writable atom holding the state model | | `store` | Jotai `Store` | No | The Jotai store instance. Defaults to a new store created internally. Pass your own to share state with ``. | ================================================ FILE: packages/jotai/package.json ================================================ { "name": "@json-render/jotai", "version": "0.14.1", "license": "Apache-2.0", "description": "Jotai adapter for json-render StateStore", "keywords": [ "json-render", "jotai", "state-management", "adapter" ], "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", "directory": "packages/jotai" }, "homepage": "https://json-render.dev", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "publishConfig": { "access": "public" }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } }, "files": [ "dist" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "check-types": "tsc --noEmit" }, "dependencies": { "@json-render/core": "workspace:*" }, "peerDependencies": { "jotai": ">=2.0.0" }, "devDependencies": { "@internal/typescript-config": "workspace:*", "jotai": "^2.18.0", "tsup": "^8.0.2", "typescript": "^5.4.5" } } ================================================ FILE: packages/jotai/src/index.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { atom } from "jotai"; import { createStore } from "jotai/vanilla"; import { jotaiStateStore } from "./index"; function createTestStore(initial: Record = {}) { const stateAtom = atom>(initial); const jStore = createStore(); const store = jotaiStateStore({ atom: stateAtom, store: jStore }); return { stateAtom, jStore, store }; } describe("jotaiStateStore", () => { it("get/set round-trip", () => { const { store } = createTestStore({ count: 0 }); expect(store.get("/count")).toBe(0); store.set("/count", 42); expect(store.get("/count")).toBe(42); expect(store.getSnapshot().count).toBe(42); }); it("update round-trip with multiple values", () => { const { store } = createTestStore({}); store.update({ "/a": 1, "/b": "hello" }); expect(store.get("/a")).toBe(1); expect(store.get("/b")).toBe("hello"); expect(store.getSnapshot()).toEqual({ a: 1, b: "hello" }); }); it("subscribe fires on set", () => { const { store } = createTestStore({}); const listener = vi.fn(); store.subscribe(listener); store.set("/x", 1); expect(listener).toHaveBeenCalledTimes(1); }); it("subscribe fires on update", () => { const { store } = createTestStore({}); const listener = vi.fn(); store.subscribe(listener); store.update({ "/a": 1, "/b": 2 }); expect(listener).toHaveBeenCalledTimes(1); }); it("unsubscribe stops notifications", () => { const { store } = createTestStore({}); const listener = vi.fn(); const unsub = store.subscribe(listener); store.set("/x", 1); expect(listener).toHaveBeenCalledTimes(1); unsub(); store.set("/x", 2); expect(listener).toHaveBeenCalledTimes(1); }); it("getSnapshot immutability -- previous snapshot is not mutated", () => { const { store } = createTestStore({ user: { name: "Alice", age: 30 } }); const snap1 = store.getSnapshot(); store.set("/user/name", "Bob"); const snap2 = store.getSnapshot(); expect(snap1.user).toEqual({ name: "Alice", age: 30 }); expect((snap2.user as Record).name).toBe("Bob"); expect(snap1.user).not.toBe(snap2.user); }); it("structural sharing -- untouched branches keep references", () => { const { store } = createTestStore({ a: { x: 1 }, b: { y: 2 }, }); const snap1 = store.getSnapshot(); store.set("/a/x", 99); const snap2 = store.getSnapshot(); expect(snap2.b).toBe(snap1.b); expect(snap2.a).not.toBe(snap1.a); }); it("getServerSnapshot returns same as getSnapshot", () => { const { store } = createTestStore({ x: 1 }); expect(store.getServerSnapshot!()).toBe(store.getSnapshot()); store.set("/x", 2); expect(store.getServerSnapshot!()).toBe(store.getSnapshot()); }); it("set skips update when value is unchanged", () => { const { store } = createTestStore({ x: 1 }); const snap1 = store.getSnapshot(); const listener = vi.fn(); store.subscribe(listener); store.set("/x", 1); expect(listener).not.toHaveBeenCalled(); expect(store.getSnapshot()).toBe(snap1); }); it("update skips update when no values changed", () => { const { store } = createTestStore({ a: 1, b: 2 }); const snap1 = store.getSnapshot(); const listener = vi.fn(); store.subscribe(listener); store.update({ "/a": 1, "/b": 2 }); expect(listener).not.toHaveBeenCalled(); expect(store.getSnapshot()).toBe(snap1); }); it("reads from the shared Jotai store", () => { const stateAtom = atom>({ count: 0 }); const jStore = createStore(); const store = jotaiStateStore({ atom: stateAtom, store: jStore }); jStore.set(stateAtom, { count: 99 }); expect(store.get("/count")).toBe(99); expect(store.getSnapshot().count).toBe(99); }); it("creates an internal store when none is provided", () => { const stateAtom = atom>({ value: "hello" }); const store = jotaiStateStore({ atom: stateAtom }); expect(store.get("/value")).toBe("hello"); store.set("/value", "world"); expect(store.get("/value")).toBe("world"); }); }); ================================================ FILE: packages/jotai/src/index.ts ================================================ import type { StateModel, StateStore } from "@json-render/core"; import { createStoreAdapter } from "@json-render/core/store-utils"; import type { WritableAtom } from "jotai"; import { createStore as createJotaiStore } from "jotai/vanilla"; export type { StateStore } from "@json-render/core"; type JotaiStore = ReturnType; /** * Options for {@link jotaiStateStore}. */ export interface JotaiStateStoreOptions { /** A writable atom that holds the json-render state model. */ atom: WritableAtom; /** * The Jotai store instance. Defaults to `createStore()` from `jotai/vanilla`. * Pass your own if you use a `` in your React tree. */ store?: JotaiStore; } /** * Create a {@link StateStore} backed by a Jotai atom. * * @example * ```ts * import { atom } from "jotai"; * import { jotaiStateStore } from "@json-render/jotai"; * * const uiAtom = atom>({ count: 0 }); * * const store = jotaiStateStore({ atom: uiAtom }); * * ... * ``` * * @example With a shared Jotai store: * ```ts * import { atom, createStore } from "jotai"; * * const jStore = createStore(); * const uiAtom = atom>({ count: 0 }); * * const store = jotaiStateStore({ atom: uiAtom, store: jStore }); * * // In React: * * ... * * ``` */ export function jotaiStateStore(options: JotaiStateStoreOptions): StateStore { const stateAtom = options.atom; const jStore = options.store ?? createJotaiStore(); return createStoreAdapter({ getSnapshot: () => jStore.get(stateAtom), setSnapshot: (next) => jStore.set(stateAtom, next), subscribe: (listener) => jStore.sub(stateAtom, listener), }); } ================================================ FILE: packages/jotai/tsconfig.json ================================================ { "extends": "@internal/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/jotai/tsup.config.ts ================================================ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts"], format: ["cjs", "esm"], dts: true, sourcemap: true, clean: true, external: ["@json-render/core", "@json-render/core/store-utils", "jotai"], }); ================================================ FILE: packages/mcp/CHANGELOG.md ================================================ # @json-render/mcp ## 0.14.1 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 ## 0.14.0 ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 ## 0.13.0 ### Patch Changes - Updated dependencies [5b32de8] - @json-render/core@0.13.0 ## 0.12.1 ### Patch Changes - 54a1ecf: Rename generation modes and fix MCP React duplicate module error. ### Changed: - **`@json-render/core`** — Renamed generation modes from `"generate"` / `"chat"` to `"standalone"` / `"inline"`. The old names still work but emit a deprecation warning. ### Fixed: - **`@json-render/mcp`** — Resolved React duplicate module error (`useRef` returning null) by adding `resolve.dedupe` Vite configuration. Added `./build-app-html` export entry point. ### Other: - Updated `homepage` URLs across all packages to point to `https://json-render.dev`. - Reorganized skills directory structure for cleaner naming. - Added skills documentation page to the web app. - Updated dependencies [54a1ecf] - @json-render/core@0.12.1 ## 0.12.0 ### Minor Changes - 63c339b: Add Svelte renderer, React Email renderer, and MCP Apps integration. ### New: - **`@json-render/svelte`** — Svelte 5 renderer with runes-based reactivity. Full support for data binding, visibility, actions, validation, watchers, streaming, and repeat scopes. Includes `defineRegistry`, `Renderer`, `schema`, composables, and context providers. - **`@json-render/react-email`** — React Email renderer for generating HTML and plain-text emails from JSON specs. 17 standard components (Html, Head, Body, Container, Section, Row, Column, Heading, Text, Link, Button, Image, Hr, Preview, Markdown). Server-side `renderToHtml` / `renderToPlainText` APIs. Custom catalog and registry support. - **`@json-render/mcp`** — MCP Apps integration that serves json-render UIs as interactive apps inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients. `createMcpApp` server factory, `useJsonRenderApp` React hook for iframes, and `buildAppHtml` utility. ### Fixed: - **`@json-render/svelte`** — Corrected JSDoc comment and added missing `zod` peer dependency. ### Patch Changes - Updated dependencies [63c339b] - @json-render/core@0.12.0 ================================================ FILE: packages/mcp/README.md ================================================ # @json-render/mcp MCP Apps integration for [json-render](https://github.com/vercel-labs/json-render). Serve json-render UIs as interactive MCP Apps inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients. ## What are MCP Apps? [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that lets MCP servers return interactive HTML UIs rendered directly inside chat conversations. Instead of text-only tool responses, users get full interactive interfaces -- dashboards, forms, data visualizations -- embedded inline. ## Installation ```bash npm install @json-render/mcp @json-render/core @modelcontextprotocol/sdk ``` ## Quick Start ### 1. Define your catalog ```ts import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog"; const catalog = defineCatalog(schema, { components: { ...shadcnComponentDefinitions }, actions: {}, }); ``` ### 2. Create the MCP server ```ts import { createMcpApp } from "@json-render/mcp"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import fs from "node:fs"; const server = createMcpApp({ name: "My Dashboard", version: "1.0.0", catalog, html: fs.readFileSync("dist/index.html", "utf-8"), }); await server.connect(new StdioServerTransport()); ``` ### 3. Build the UI (iframe) Create a React app that uses `useJsonRenderApp` from `@json-render/mcp/app`: ```tsx import { useJsonRenderApp } from "@json-render/mcp/app"; import { JSONUIProvider, Renderer } from "@json-render/react"; function McpAppView({ registry }) { const { spec, loading, connected, error } = useJsonRenderApp(); if (error) return

Error: {error.message}
; if (!spec) return
Waiting for spec...
; return ( ); } ``` Bundle with Vite + `vite-plugin-singlefile` into a single HTML file, then pass it to `createMcpApp` as the `html` option. ### 4. Connect to a client Add to `.cursor/mcp.json` or Claude Desktop config: ```json { "mcpServers": { "my-app": { "command": "node", "args": ["./server.js", "--stdio"] } } } ``` ## API Reference ### Server Side (main export) #### `createMcpApp(options)` Creates a fully-configured `McpServer` with a json-render tool and UI resource. | Option | Type | Description | |--------|------|-------------| | `name` | `string` | Server name shown in client UIs | | `version` | `string` | Server version | | `catalog` | `Catalog` | json-render catalog defining available components | | `html` | `string` | Bundled HTML for the iframe UI | | `tool` | `McpToolOptions` | Optional tool name/title/description overrides | #### `registerJsonRenderTool(server, options)` Register a json-render tool on an existing `McpServer`. #### `registerJsonRenderResource(server, options)` Register a json-render UI resource on an existing `McpServer`. ### Client Side (`@json-render/mcp/app`) #### `useJsonRenderApp(options?)` React hook for the iframe-side app. Connects to the MCP host, receives tool results, and maintains the current json-render spec. Returns `{ spec, loading, connected, connecting, error, app, callServerTool }`. #### `buildAppHtml(options)` Generate a self-contained HTML string from bundled JS/CSS for use as a UI resource. ## Client Support MCP Apps are supported by Claude, ChatGPT, VS Code (Copilot), Cursor, Goose, and Postman. ================================================ FILE: packages/mcp/package.json ================================================ { "name": "@json-render/mcp", "version": "0.14.1", "license": "Apache-2.0", "description": "MCP Apps integration for @json-render/core. Serve json-render UIs as interactive MCP Apps in Claude, ChatGPT, Cursor, and VS Code.", "keywords": [ "json", "ui", "mcp", "model-context-protocol", "mcp-apps", "ai", "generative-ui", "llm", "renderer", "claude", "chatgpt", "cursor" ], "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", "directory": "packages/mcp" }, "homepage": "https://json-render.dev", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "publishConfig": { "access": "public" }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" }, "./app": { "types": "./dist/app.d.ts", "import": "./dist/app.mjs", "require": "./dist/app.js" }, "./build-app-html": { "types": "./dist/build-app-html-entry.d.ts", "import": "./dist/build-app-html-entry.mjs", "require": "./dist/build-app-html-entry.js" } }, "files": [ "dist" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit" }, "dependencies": { "@json-render/core": "workspace:*", "@modelcontextprotocol/ext-apps": "^1.2.0", "@modelcontextprotocol/sdk": "^1.27.1" }, "devDependencies": { "@internal/typescript-config": "workspace:*", "@types/react": "19.2.3", "tsup": "^8.0.2", "typescript": "^5.4.5" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, "peerDependenciesMeta": { "react": { "optional": true }, "react-dom": { "optional": true } } } ================================================ FILE: packages/mcp/src/app.ts ================================================ /** * Client-side (iframe) utilities for rendering json-render specs * inside an MCP App view. * * This module is intended to run **inside the sandboxed iframe** that * MCP hosts render. It connects to the host via the MCP Apps protocol, * receives tool results containing json-render specs, and provides * React hooks / helpers to render them. * * @example * ```tsx * import { useJsonRenderApp } from "@json-render/mcp/app"; * import { Renderer } from "@json-render/react"; * * function McpAppView({ registry }) { * const { spec, loading } = useJsonRenderApp(); * return ; * } * ``` * * @packageDocumentation */ export { useJsonRenderApp } from "./use-json-render-app.js"; export type { UseJsonRenderAppOptions, UseJsonRenderAppReturn, } from "./use-json-render-app.js"; export { buildAppHtml } from "./build-app-html.js"; export type { BuildAppHtmlOptions } from "./build-app-html.js"; ================================================ FILE: packages/mcp/src/build-app-html-entry.ts ================================================ /** * Server-side utility for building self-contained MCP App HTML. * * This entry point does NOT depend on React and is safe to import * in Node.js / server environments. * * @packageDocumentation */ export { buildAppHtml } from "./build-app-html.js"; export type { BuildAppHtmlOptions } from "./build-app-html.js"; ================================================ FILE: packages/mcp/src/build-app-html.ts ================================================ /** * Options for `buildAppHtml`. */ export interface BuildAppHtmlOptions { /** Title for the HTML page. Defaults to `"json-render"`. */ title?: string; /** * Inline CSS to inject into the page `
`; } function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } ================================================ FILE: packages/mcp/src/index.ts ================================================ export type { CreateMcpAppOptions, McpToolOptions, RegisterToolOptions, RegisterResourceOptions, } from "./types.js"; export { createMcpApp, registerJsonRenderTool, registerJsonRenderResource, } from "./server.js"; ================================================ FILE: packages/mcp/src/server.ts ================================================ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CreateMcpAppOptions, RegisterToolOptions, RegisterResourceOptions, } from "./types.js"; const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; /** * Dynamically import the ext-apps server helpers to avoid CJS/ESM type * mismatches at compile time while still getting the proper runtime * `_meta.ui` normalization that hosts require. */ async function getExtApps() { const mod = await import("@modelcontextprotocol/ext-apps/server"); return mod; } /** * Register a json-render tool on an existing MCP server. * * Uses `registerAppTool` from `@modelcontextprotocol/ext-apps/server` * so that MCP Apps-capable hosts (Claude, VS Code, Cursor, ChatGPT) * see the `_meta.ui.resourceUri` in the tool listing and know to * fetch and render the `ui://` resource as an interactive iframe. * * The tool accepts a json-render spec as input and returns it as text * content, which the iframe receives via `ontoolresult`. */ export async function registerJsonRenderTool( server: McpServer, options: RegisterToolOptions, ): Promise { const { catalog, name, title, description, resourceUri } = options; const { registerAppTool } = await getExtApps(); const specZodSchema = catalog.zodSchema(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (registerAppTool as any)( server, name, { title, description, inputSchema: { spec: specZodSchema }, _meta: { ui: { resourceUri } }, }, async (args: { spec?: unknown }) => { const spec = args.spec; const validation = catalog.validate(spec); const validSpec = validation.success ? validation.data : spec; return { content: [ { type: "text" as const, text: JSON.stringify(validSpec), }, ], }; }, ); } /** * Register a json-render UI resource on an existing MCP server. * * The resource serves the self-contained HTML page that renders * json-render specs received from tool results. */ export async function registerJsonRenderResource( server: McpServer, options: RegisterResourceOptions, ): Promise { const { resourceUri, html } = options; const { registerAppResource, RESOURCE_MIME_TYPE: mimeType } = await getExtApps(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (registerAppResource as any)( server, resourceUri, resourceUri, { mimeType }, async () => ({ contents: [ { uri: resourceUri, mimeType, text: html, _meta: { ui: { csp: { resourceDomains: ["https:"], connectDomains: ["https:"], }, }, }, }, ], }), ); } /** * Create a fully-configured MCP server that serves a json-render catalog * as an MCP App. * * This is the main entry point for most users. It creates an `McpServer`, * registers the render tool and UI resource, and returns the server * ready for transport connection. * * @example * ```ts * import { createMcpApp } from "@json-render/mcp"; * * const server = createMcpApp({ * name: "My Dashboard", * version: "1.0.0", * catalog: myCatalog, * html: myBundledHtml, * }); * * // Connect via stdio * import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; * await server.connect(new StdioServerTransport()); * ``` */ export async function createMcpApp( options: CreateMcpAppOptions, ): Promise { const { name, version, catalog, html, tool } = options; const toolName = tool?.name ?? "render-ui"; const toolTitle = tool?.title ?? "Render UI"; const resourceUri = `ui://${toolName}/view.html`; const catalogPrompt = catalog.prompt(); const toolDescription = tool?.description ?? `Render an interactive UI. The spec argument must be a json-render spec conforming to the catalog.\n\n${catalogPrompt}`; const server = new McpServer({ name, version }); await registerJsonRenderTool(server, { catalog, name: toolName, title: toolTitle, description: toolDescription, resourceUri, }); await registerJsonRenderResource(server, { resourceUri, html }); return server; } ================================================ FILE: packages/mcp/src/types.ts ================================================ import type { Catalog } from "@json-render/core"; /** * Options for creating an MCP App server backed by a json-render catalog. */ export interface CreateMcpAppOptions { /** Display name for the MCP server shown in client UIs. */ name: string; /** Semantic version of the MCP server. */ version: string; /** The json-render catalog defining available components and actions. */ catalog: Catalog; /** * Pre-built HTML string for the UI resource. * Generate this with `buildAppHtml` from `@json-render/mcp/app` or * provide your own self-contained HTML page. */ html: string; /** * Optional tool configuration overrides. */ tool?: McpToolOptions; } /** * Options for configuring the MCP tool that renders json-render specs. */ export interface McpToolOptions { /** Tool name exposed to the LLM. Defaults to `"render-ui"`. */ name?: string; /** Human-readable title. Defaults to `"Render UI"`. */ title?: string; /** Tool description shown to the LLM. When omitted, a description is * auto-generated from the catalog prompt. */ description?: string; } /** * Options for registering the MCP App tool. */ export interface RegisterToolOptions { /** The json-render catalog. */ catalog: Catalog; /** Tool name. */ name: string; /** Tool title. */ title: string; /** Tool description. */ description: string; /** The `ui://` resource URI this tool's view is served from. */ resourceUri: string; } /** * Options for registering the MCP App UI resource. */ export interface RegisterResourceOptions { /** The `ui://` resource URI. */ resourceUri: string; /** Self-contained HTML string for the view. */ html: string; } ================================================ FILE: packages/mcp/src/use-json-render-app.ts ================================================ import { useState, useEffect, useCallback, useRef } from "react"; import type { Spec } from "@json-render/core"; import { App } from "@modelcontextprotocol/ext-apps"; /** * Options for the `useJsonRenderApp` hook. */ export interface UseJsonRenderAppOptions { /** App name shown during initialization. Defaults to `"json-render"`. */ name?: string; /** App version. Defaults to `"1.0.0"`. */ version?: string; } /** * Return value of `useJsonRenderApp`. */ export interface UseJsonRenderAppReturn { /** The current json-render spec (null until the first tool result). */ spec: Spec | null; /** Whether the app is still connecting to the host. */ connecting: boolean; /** Whether the app is connected to the host. */ connected: boolean; /** Connection error, if any. */ error: Error | null; /** Whether the spec is still being received / parsed. */ loading: boolean; /** The underlying MCP App instance. */ app: App | null; /** * Call a tool on the MCP server and update the spec from the result. * Useful for refresh / drill-down interactions. */ callServerTool: ( name: string, args?: Record, ) => Promise; } interface ToolResultContent { type: string; text?: string; } function parseSpecFromToolResult(result: { content?: ToolResultContent[]; }): Spec | null { const textContent = result.content?.find( (c: ToolResultContent) => c.type === "text", ); if (!textContent?.text) return null; try { const parsed = JSON.parse(textContent.text); if (parsed && typeof parsed === "object" && "spec" in parsed) { return parsed.spec as Spec; } return parsed as Spec; } catch { return null; } } /** * React hook that connects to the MCP host, listens for tool results, * and maintains the current json-render spec. * * Follows the official MCP Apps pattern: create an `App` instance, * register the `ontoolresult` handler, then call `app.connect()` * which internally creates a PostMessageTransport to the host. */ export function useJsonRenderApp( options: UseJsonRenderAppOptions = {}, ): UseJsonRenderAppReturn { const { name = "json-render", version = "1.0.0" } = options; const [spec, setSpec] = useState(null); const [loading, setLoading] = useState(true); const [connected, setConnected] = useState(false); const [error, setError] = useState(null); const appRef = useRef(null); useEffect(() => { const app = new App({ name, version }); appRef.current = app; app.ontoolresult = (result: { content?: ToolResultContent[] }) => { const parsed = parseSpecFromToolResult(result); if (parsed) { setSpec(parsed); setLoading(false); } }; // Let the App class handle transport creation internally, // matching the official MCP Apps quickstart pattern. app .connect() .then(() => { setConnected(true); }) .catch((err: unknown) => { setError(err instanceof Error ? err : new Error(String(err))); }); return () => { app.close().catch(() => {}); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const callServerTool = useCallback( async (toolName: string, args: Record = {}) => { if (!appRef.current) return; setLoading(true); try { const result = await appRef.current.callServerTool({ name: toolName, arguments: args, }); const parsed = parseSpecFromToolResult(result); if (parsed) setSpec(parsed); } finally { setLoading(false); } }, [], ); return { spec, connecting: !connected && !error, connected, error, loading, app: appRef.current, callServerTool, }; } ================================================ FILE: packages/mcp/tsconfig.json ================================================ { "extends": "@internal/typescript-config/react-library.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/mcp/tsup.config.ts ================================================ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts", "src/app.ts", "src/build-app-html-entry.ts"], format: ["cjs", "esm"], dts: true, sourcemap: true, clean: true, external: [ "react", "react-dom", "@json-render/core", "@json-render/react", "@modelcontextprotocol/sdk", "@modelcontextprotocol/ext-apps", ], }); ================================================ FILE: packages/react/CHANGELOG.md ================================================ # @json-render/react ## 0.14.1 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 ## 0.14.0 ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 ## 0.13.0 ### Patch Changes - Updated dependencies [5b32de8] - @json-render/core@0.13.0 ## 0.12.1 ### Patch Changes - Updated dependencies [54a1ecf] - @json-render/core@0.12.1 ## 0.12.0 ### Patch Changes - Updated dependencies [63c339b] - @json-render/core@0.12.0 ## 0.11.0 ### Patch Changes - Updated dependencies [3f1e71e] - @json-render/core@0.11.0 ## 0.10.0 ### Minor Changes - 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values. ### New: `@json-render/vue` Package Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming. - `defineRegistry` — create type-safe component registries from catalogs - `Renderer` — render specs as Vue component trees - Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider` - Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation` - Streaming: `useUIStream`, `useChatUI` - External store support via `StateStore` interface ### New: `@json-render/xstate` Package XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend. - `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom - Requires `@xstate/store` v3+ ### New: `$computed` Expressions Call registered functions from prop expressions: - `{ "$computed": "functionName", "args": { "key": } }` — calls a named function with resolved args - Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer` - `ComputedFunction` type exported from `@json-render/core` ### New: `$template` Expressions Interpolate state values into strings: - `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values - Missing paths resolve to empty string ### New: State Watchers React to state changes by triggering actions: - `watch` field on elements maps state paths to action bindings - Fires when watched values change (not on initial render) - Supports cascading dependencies (e.g. country → city loading) - `watch` is a top-level field on elements (sibling of type/props/children), not inside props - Spec validator detects and auto-fixes `watch` placed inside props ### New: Cross-Field Validation Functions New built-in validation functions for cross-field comparisons: - `equalTo` — alias for `matches` with clearer semantics - `lessThan` — value must be less than another field (numbers, strings, coerced) - `greaterThan` — value must be greater than another field - `requiredIf` — required only when a condition field is truthy - Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling ### New: `validateForm` Action (React) Built-in action that validates all registered form fields at once: - Runs `validateAll()` synchronously and writes `{ valid, errors }` to state - Default state path: `/formValidation` (configurable via `statePath` param) - Added to React schema's built-in actions list ### Improved: shadcn/ui Validation All form components now support validation: - Checkbox, Radio, Switch — added `checks` and `validateOn` props - Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit) - Shared validation schemas reduce catalog definition duplication ### Improved: React Provider Tree Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access. ### Patch Changes - Updated dependencies [9cef4e9] - @json-render/core@0.10.0 ## 0.9.1 ### Patch Changes - b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm. - @json-render/core@0.9.1 ## 0.9.0 ### Minor Changes - 1d755c1: External state store, store adapters, and bug fixes. ### New: External State Store The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode. - Added `StateStore` interface and `createStateStore()` factory to `@json-render/core` - `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop for controlled mode - When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored) - When `store` is omitted, everything works exactly as before (fully backward compatible) - Applied across all platform packages: react, react-native, react-pdf - Store utilities (`createStoreAdapter`, `immutableSetByPath`, `flattenToPointers`) available via `@json-render/core/store-utils` for building custom adapters ### New: Store Adapter Packages - `@json-render/zustand` — Zustand adapter for `StateStore` - `@json-render/redux` — Redux / Redux Toolkit adapter for `StateStore` - `@json-render/jotai` — Jotai adapter for `StateStore` ### Changed: `onStateChange` signature updated (breaking) The `onStateChange` callback now receives a single array of changed entries instead of being called once per path: ```ts // Before onStateChange?: (path: string, value: unknown) => void // After onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void ``` ### Fixed - Fix schema import to use server-safe `@json-render/react/schema` subpath, avoiding `createContext` crashes in Next.js App Router API routes - Fix chaining actions in `@json-render/react`, `@json-render/react-native`, and `@json-render/react-pdf` - Fix safely resolving inner type for Zod arrays in core schema ### Patch Changes - Updated dependencies [1d755c1] - @json-render/core@0.9.0 - @internal/react-state@0.8.1 ## 0.8.0 ### Patch Changes - Updated dependencies [09376db] - @json-render/core@0.8.0 ## 0.7.0 ### Minor Changes - 2d70fab: New `@json-render/shadcn` package, event handles, built-in actions, and stream improvements. ### New: `@json-render/shadcn` Package Pre-built [shadcn/ui](https://ui.shadcn.com/) component library for json-render. 30+ components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`. - `shadcnComponentDefinitions` — Zod-based catalog definitions for all components (server-safe, no React dependency via `@json-render/shadcn/catalog`) - `shadcnComponents` — React implementations for all components - Layout: Card, Stack, Grid, Separator - Navigation: Tabs, Accordion, Collapsible, Pagination - Overlay: Dialog, Drawer, Tooltip, Popover, DropdownMenu - Content: Heading, Text, Image, Avatar, Badge, Alert, Carousel, Table - Feedback: Progress, Skeleton, Spinner - Input: Button, Link, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Toggle, ToggleGroup, ButtonGroup ### New: Event Handles (`on()`) Components now receive an `on(event)` function in addition to `emit(event)`. The `on()` function returns an `EventHandle` with metadata: - `emit()` — fire the event - `shouldPreventDefault` — whether any action binding requested `preventDefault` - `bound` — whether any handler is bound to this event ### New: `BaseComponentProps` Catalog-agnostic base type for component render functions. Use when building reusable component libraries (like `@json-render/shadcn`) that are not tied to a specific catalog. ### New: Built-in Actions in Schema Schemas can now declare `builtInActions` — actions that are always available at runtime and automatically injected into prompts. The React schema declares `setState`, `pushState`, and `removeState` as built-in, so they appear in prompts without needing to be listed in catalog `actions`. ### New: `preventDefault` on `ActionBinding` Action bindings now support a `preventDefault` boolean field, allowing the LLM to request that default browser behavior (e.g. navigation on links) be prevented. ### Improved: Stream Transform Text Block Splitting `createJsonRenderTransform()` now properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs. This ensures the AI SDK creates separate text parts, preserving correct interleaving of prose and UI in `message.parts`. ### Improved: `defineRegistry` Actions Requirement `defineRegistry` now conditionally requires the `actions` field only when the catalog declares actions. Catalogs with no actions (e.g. `actions: {}`) no longer need to pass an empty actions object. ### Patch Changes - Updated dependencies [2d70fab] - @json-render/core@0.7.0 ## 0.6.1 ### Patch Changes - 43ad534: Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at the same empty path with different `checks` configs, causing them to overwrite each other endlessly. Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update. - @json-render/core@0.6.1 ## 0.6.1 ### Patch Changes - ea97aff: Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at the same empty path with different `checks` configs, causing them to overwrite each other endlessly. Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update. - Updated dependencies [ea97aff] - @json-render/core@0.6.1 ## 0.6.0 ### Minor Changes - 06b8745: Chat mode (inline GenUI), AI SDK integration, two-way binding, and expression-based visibility/props. ### New: Chat Mode (Inline GenUI) Two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets AI respond conversationally with embedded UI specs — ideal for chatbots and copilot experiences. - `catalog.prompt({ mode: "chat" })` generates a chat-aware system prompt - `pipeJsonRender()` server-side transform separates text from JSONL patches in a mixed stream - `createJsonRenderTransform()` low-level TransformStream for custom pipelines ### New: AI SDK Integration First-class Vercel AI SDK support with typed data parts and stream utilities. - `SpecDataPart` type for `data-spec` stream parts (patch, flat, nested payloads) - `SPEC_DATA_PART` / `SPEC_DATA_PART_TYPE` constants for type-safe part filtering - `createMixedStreamParser()` for parsing mixed text + JSONL streams ### New: React Chat Hooks - `useChatUI()` — full chat hook with message history, streaming, and spec extraction - `useJsonRenderMessage()` — extract spec + text from a message's parts array - `buildSpecFromParts()` / `getTextFromParts()` — utilities for working with AI SDK message parts - `useBoundProp()` — two-way binding hook for `$bindState` / `$bindItem` expressions ### New: Two-Way Binding Props can now use `$bindState` and `$bindItem` expressions for two-way data binding. The renderer resolves bindings and passes a `bindings` map to components, enabling write-back to state. ### New: Expression-Based Props and Visibility Replaced string token rewriting with structured expression objects: - Props: `{ $state: "/path" }`, `{ $item: "field" }`, `{ $index: true }` - Visibility: `{ $state: "/path", eq: "value" }`, `{ $item: "active" }`, `{ $index: true, gt: 0 }` - Logic: `{ $and: [...] }`, `{ $or: [...] }`, and implicit AND via arrays - Comparison operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not` ### New: Utilities - `applySpecPatch()` — typed convenience wrapper for applying a single patch to a Spec - `nestedToFlat()` — convert nested tree specs to flat `{ root, elements }` format - `resolveBindings()` / `resolveActionParam()` — resolve binding paths and action params ### New: Chat Example Full-featured chat example (`examples/chat`) with AI agent, tool calls (crypto, GitHub, Hacker News, weather, search), theme toggle, and streaming UI generation. ### Improved: Renderer - `ElementRenderer` is now `React.memo`'d for better performance in repeat lists - `emit` is always defined (never `undefined`) — no more optional chaining needed - Action params are resolved through `resolveActionParam` supporting `$item`, `$index`, `$state` - Repeat scope now passes the actual item object instead of requiring token rewriting ### Breaking Changes - **Expressions renamed**: `{ $path }` / `{ path }` replaced by `{ $state }`, `{ $item }`, `{ $index }` - **Visibility conditions**: `{ path }` → `{ $state }`, `{ and/or/not }` → `{ $and/$or }` with `not` as operator flag - **DynamicValue**: `{ path: string }` → `{ $state: string }` - **Repeat field**: `repeat.path` → `repeat.statePath` - **Action params**: `path` → `statePath` in setState action params - **Provider props**: `actionHandlers` → `handlers` on `JSONUIProvider`/`ActionProvider` - **Auth removed**: `AuthState` type and `{ auth }` visibility conditions removed — model auth as regular state - **Legacy catalog removed**: `createCatalog`, `generateCatalogPrompt`, `generateSystemPrompt`, `ComponentDefinition`, `CatalogConfig`, `SystemPromptOptions` removed - **React exports removed**: `createRendererFromCatalog`, `rewriteRepeatTokens` - **Codegen**: `traverseTree` → `traverseSpec`, `SpecVisitor` → `TreeVisitor` ### Patch Changes - Updated dependencies [06b8745] - @json-render/core@0.6.0 ## 0.5.2 ### Patch Changes - 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section. - Updated dependencies [429e456] - @json-render/core@0.5.2 ## 0.5.1 ### Patch Changes - d9a4efd: Prevent rendering errors from crashing the application. Added error boundaries to all renderers so a single bad component silently disappears instead of causing a white-screen-of-death. Fixed Select and Radio components to handle non-string option values from AI output. - @json-render/core@0.5.1 ## 0.5.0 ### Minor Changes - 3d2d1ad: Add @json-render/react-native package, event system (emit replaces onAction), repeat/list rendering, user prompt builder, spec validation, and rename DataProvider to StateProvider. ### Patch Changes - Updated dependencies [3d2d1ad] - @json-render/core@0.5.0 ## 0.4.4 ### Patch Changes - dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream - Updated dependencies [dd17549] - @json-render/core@0.4.4 ## 0.4.3 ### Patch Changes - 61ee8e5: include remove op in system prompt - Updated dependencies [61ee8e5] - @json-render/core@0.4.3 ## 0.4.2 ### Patch Changes - 54bce09: add defineRegistry function - Updated dependencies [54bce09] - @json-render/core@0.4.2 ================================================ FILE: packages/react/README.md ================================================ # @json-render/react React renderer for json-render. Turn JSON specs into React components with data binding, visibility, and actions. ## Installation ```bash npm install @json-render/react @json-render/core zod ``` ## Quick Start ### 1. Create a Catalog ```typescript import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { z } from "zod"; export const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string(), description: z.string().nullable(), }), description: "A card container", }, Button: { props: z.object({ label: z.string(), action: z.string(), }), description: "A clickable button", }, Input: { props: z.object({ value: z.union([z.string(), z.record(z.unknown())]).nullable(), label: z.string(), placeholder: z.string().nullable(), }), description: "Text input field with optional value binding", }, }, actions: { submit: { description: "Submit the form" }, cancel: { description: "Cancel and close" }, }, }); ``` ### 2. Define Component Implementations `defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it entirely. ```tsx import { defineRegistry, useBoundProp } from "@json-render/react"; import { catalog } from "./catalog"; export const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => (

{props.title}

{props.description &&

{props.description}

} {children}
), Button: ({ props, emit }) => ( ), Input: ({ props, bindings }) => { const [value, setValue] = useBoundProp(props.value, bindings?.value); return ( ); }, }, }); ``` ### 3. Render Specs ```tsx import { Renderer, StateProvider, ActionProvider } from "@json-render/react"; import { registry } from "./registry"; function App({ spec }) { return ( console.log("Submit"), }}> ); } ``` ## Spec Format The React renderer uses a flat element map format: ```typescript interface Spec { root: string; // Key of the root element elements: Record; // Flat map of elements by key state?: Record; // Optional initial state } interface UIElement { type: string; // Component name from catalog props: Record; // Component props children?: string[]; // Keys of child elements visible?: VisibilityCondition; // Visibility condition } ``` Example spec: ```json { "root": "card-1", "elements": { "card-1": { "type": "Card", "props": { "title": "Welcome" }, "children": ["input-1", "btn-1"] }, "input-1": { "type": "Input", "props": { "value": { "$bindState": "/form/name" }, "label": "Name", "placeholder": "Enter name" } }, "btn-1": { "type": "Button", "props": { "label": "Submit" }, "children": [] } } } ``` ## Contexts ### StateProvider Share data across components with JSON Pointer paths: ```tsx {children} // In components: const { state, get, set } = useStateStore(); const name = get("/user/name"); // "John" set("/user/age", 25); ``` #### External Store (Controlled Mode) For full control over state, pass a `StateStore` to bypass the internal state and wire json-render to any state management library (Redux, Zustand, XState, etc.): ```tsx import { createStateStore, type StateStore } from "@json-render/react"; // Option 1: Use the built-in store outside of React const store = createStateStore({ count: 0 }); {children} // Mutate from anywhere — React will re-render automatically: store.set("/count", 1); // Option 2: Implement the StateStore interface with your own backend const zustandStore: StateStore = { get: (path) => getByPath(useStore.getState(), path), set: (path, value) => useStore.setState(prev => { /* ... */ }), update: (updates) => useStore.setState(prev => { /* ... */ }), getSnapshot: () => useStore.getState(), subscribe: (listener) => useStore.subscribe(listener), }; ``` When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. The same `store` prop is available on `createRenderer`, `JSONUIProvider`, and `StateProvider`. ### ActionProvider Handle actions from components: ```tsx handleSubmit(params), cancel: () => handleCancel(), }} > {children} ``` ### VisibilityProvider Control element visibility based on data: ```tsx {children} // Elements can use visibility conditions: { "type": "Alert", "props": { "message": "Error!" }, "visible": { "$state": "/form/hasError" } } ``` ### ValidationProvider Add field validation: ```tsx {children} // Use validation hooks: const { errors, validate } = useFieldValidation("/form/email", { checks: [ { type: "required", message: "Email required" }, { type: "email", message: "Invalid email" }, ], }); ``` ## Hooks | Hook | Purpose | |------|---------| | `useStateStore()` | Access state context (`state`, `get`, `set`, `update`) | | `useStateValue(path)` | Get single value from state | | `useStateBinding(path)` | Two-way data binding (returns `[value, setValue]`) | | `useIsVisible(condition)` | Check if a visibility condition is met | | `useActions()` | Access action context | | `useAction(name)` | Get a single action dispatch function | | `useFieldValidation(path, config)` | Field validation state | | `useOptionalValidation()` | Non-throwing validation context (returns `null` if no provider) | | `useUIStream(options)` | Stream specs from an API endpoint | ## Visibility Conditions ```typescript // Truthiness check { "$state": "/user/isAdmin" } // Auth state (use state path) { "$state": "/auth/isSignedIn" } // Comparisons (flat style) { "$state": "/status", "eq": "active" } { "$state": "/count", "gt": 10 } // Negation { "$state": "/maintenance", "not": true } // Multiple conditions (implicit AND) [ { "$state": "/feature/enabled" }, { "$state": "/maintenance", "not": true } ] // Always / never true // always visible false // never visible ``` TypeScript helpers from `@json-render/core`: ```typescript import { visibility } from "@json-render/core"; visibility.when("/path") // { $state: "/path" } visibility.unless("/path") // { $state: "/path", not: true } visibility.eq("/path", val) // { $state: "/path", eq: val } visibility.neq("/path", val) // { $state: "/path", neq: val } visibility.and(cond1, cond2) // { $and: [cond1, cond2] } visibility.always // true visibility.never // false ``` ## Dynamic Prop Expressions Any prop value can use data-driven expressions that resolve at render time. The renderer resolves these transparently before passing props to components. ```json { "type": "Badge", "props": { "label": { "$state": "/user/role" }, "color": { "$cond": { "$state": "/user/role", "eq": "admin" }, "$then": "red", "$else": "gray" } } } ``` For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`. ### `$template` and `$computed` ```json { "label": { "$template": "Hello, ${/user/name}!" }, "fullName": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } } } ``` Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`: ```tsx `${args.first} ${args.last}` }} > ``` See [@json-render/core](../core/README.md) for full expression syntax. ## State Watchers Elements can declare a `watch` field to trigger actions when state values change: ```json { "type": "Select", "props": { "label": "Country", "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] }, "watch": { "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } } }, "children": [] } ``` `watch` is a top-level field on elements (sibling of `type`/`props`/`children`), not inside `props`. Watchers only fire on value changes, not on initial render. ## Built-in Actions The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`: ```json { "type": "Button", "props": { "label": "Switch Tab" }, "on": { "press": { "action": "setState", "params": { "statePath": "/activeTab", "value": "settings" } } }, "children": [] } ``` ### `validateForm` Validate all registered form fields at once and write the result to state: ```json { "type": "Button", "props": { "label": "Submit" }, "on": { "press": [ { "action": "validateForm", "params": { "statePath": "/formResult" } }, { "action": "submitForm" } ] }, "children": [] } ``` Writes `{ valid: boolean, errors: Record }` to the specified state path (defaults to `/formValidation`). ## Component Props When using `defineRegistry`, components receive these props: ```typescript interface ComponentContext

{ props: P; // Typed props from the catalog (expressions resolved) children?: React.ReactNode; // Rendered children emit: (event: string) => void; // Emit a named event (always defined) on: (event: string) => EventHandle; // Get event handle with metadata loading?: boolean; // Whether the parent is loading bindings?: Record; // State paths for $bindState/$bindItem expressions (e.g. bindings.value) } interface EventHandle { emit: () => void; // Fire the event shouldPreventDefault: boolean; // Whether any binding requested preventDefault bound: boolean; // Whether any handler is bound } ``` Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault` or `bound`: ```tsx Link: ({ props, on }) => { const click = on("click"); return ( { if (click.shouldPreventDefault) e.preventDefault(); click.emit(); }} > {props.label} ); }, ``` Use `bindings?.value`, `bindings?.checked`, etc. with `useBoundProp()` for two-way bound form components. ### `BaseComponentProps` For building reusable component libraries that are not tied to a specific catalog (e.g. `@json-render/shadcn`), use the catalog-agnostic `BaseComponentProps` type: ```typescript import type { BaseComponentProps } from "@json-render/react"; const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (

{props.title}{children}
); ``` ## Generate AI Prompts ```typescript const systemPrompt = catalog.prompt(); // Returns detailed prompt with component/action descriptions ``` ## Full Example ```tsx import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { defineRegistry, Renderer } from "@json-render/react"; import { z } from "zod"; const catalog = defineCatalog(schema, { components: { Greeting: { props: z.object({ name: z.string() }), description: "Displays a greeting", }, }, actions: {}, }); const { registry } = defineRegistry(catalog, { components: { Greeting: ({ props }) =>

Hello, {props.name}!

, }, }); const spec = { root: "greeting-1", elements: { "greeting-1": { type: "Greeting", props: { name: "World" }, children: [], }, }, }; function App() { return ; } ``` ## Key Exports | Export | Purpose | |--------|---------| | `defineRegistry` | Create a type-safe component registry from a catalog | | `Renderer` | Render a spec using a registry | | `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`, `validateForm`) | | `useStateStore` | Access state context | | `useStateValue` | Get single value from state | | `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | | `useActions` | Access actions context | | `useAction` | Get a single action dispatch function | | `useUIStream` | Stream specs from an API endpoint | | `createStateStore` | Create a framework-agnostic in-memory `StateStore` | ### Types | Export | Purpose | |--------|---------| | `ComponentContext` | Typed component render function context (catalog-aware) | | `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries | | `EventHandle` | Event handle with `emit()`, `shouldPreventDefault`, `bound` | | `ComponentFn` | Component render function type | | `SetState` | State setter type | | `StateModel` | State model type | | `StateStore` | Interface for plugging in external state management | ================================================ FILE: packages/react/package.json ================================================ { "name": "@json-render/react", "version": "0.14.1", "license": "Apache-2.0", "description": "React renderer for @json-render/core. JSON becomes React components.", "keywords": [ "json", "ui", "react", "ai", "generative-ui", "llm", "renderer", "streaming", "components" ], "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", "directory": "packages/react" }, "homepage": "https://json-render.dev", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "publishConfig": { "access": "public" }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" }, "./schema": { "types": "./dist/schema.d.ts", "import": "./dist/schema.mjs", "require": "./dist/schema.js" } }, "files": [ "dist" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit" }, "dependencies": { "@json-render/core": "workspace:*" }, "devDependencies": { "@internal/react-state": "workspace:*", "@internal/typescript-config": "workspace:*", "@types/react": "19.2.3", "tsup": "^8.0.2", "typescript": "^5.4.5" }, "peerDependencies": { "react": "^19.2.3" } } ================================================ FILE: packages/react/src/catalog-types.ts ================================================ import type { ReactNode } from "react"; import type { Catalog, InferCatalogComponents, InferCatalogActions, InferComponentProps, InferActionParams, StateModel, } from "@json-render/core"; export type { StateModel }; // ============================================================================= // State Types // ============================================================================= /** * State setter function for updating application state */ export type SetState = ( updater: (prev: Record) => Record, ) => void; // ============================================================================= // Component Types // ============================================================================= /** * Handle returned by the `on()` function for a specific event. * Provides metadata about the event binding and a method to fire it. * * @example * ```ts * const press = on("press"); * if (press.shouldPreventDefault) e.preventDefault(); * press.emit(); * ``` */ export interface EventHandle { /** Fire the event (resolve action bindings) */ emit: () => void; /** Whether any binding requested preventDefault */ shouldPreventDefault: boolean; /** Whether any handler is bound to this event */ bound: boolean; } /** * Catalog-agnostic base type for component render function arguments. * Use this when building reusable component libraries (e.g. `@json-render/shadcn`) * that are not tied to a specific catalog. * * @example * ```ts * const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => ( *
{props.title}{children}
* ); * ``` */ export interface BaseComponentProps

> { props: P; children?: ReactNode; /** Simple event emitter (shorthand). Fires the event and returns void. */ emit: (event: string) => void; /** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */ on: (event: string) => EventHandle; /** * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions. * Maps prop name → absolute state path for write-back. */ bindings?: Record; loading?: boolean; } /** * Context passed to component render functions * @example * const Button: ComponentFn = (ctx) => { * return * } */ export interface ComponentContext< C extends Catalog, K extends keyof InferCatalogComponents, > extends BaseComponentProps> {} /** * Component render function type for React * @example * const Button: ComponentFn = ({ props, emit }) => ( * * ); */ export type ComponentFn< C extends Catalog, K extends keyof InferCatalogComponents, > = (ctx: ComponentContext) => ReactNode; /** * Registry of all component render functions for a catalog * @example * const components: Components = { * Button: ({ props }) => , * Input: ({ props }) => , * }; */ export type Components = { [K in keyof InferCatalogComponents]: ComponentFn; }; // ============================================================================= // Action Types // ============================================================================= /** * Action handler function type * @example * const viewCustomers: ActionFn = async (params, setState) => { * const data = await fetch('/api/customers'); * setState(prev => ({ ...prev, customers: data })); * }; */ export type ActionFn< C extends Catalog, K extends keyof InferCatalogActions, > = ( params: InferActionParams | undefined, setState: SetState, state: StateModel, ) => Promise; /** * Registry of all action handlers for a catalog * @example * const actions: Actions = { * viewCustomers: async (params, setState) => { ... }, * createCustomer: async (params, setState) => { ... }, * }; */ export type Actions = { [K in keyof InferCatalogActions]: ActionFn; }; /** * True when the catalog declares at least one action, false otherwise. * Used by defineRegistry to conditionally require the `actions` field. */ export type CatalogHasActions = [ InferCatalogActions, ] extends [never] ? false : [keyof InferCatalogActions] extends [never] ? false : true; ================================================ FILE: packages/react/src/chained-actions.test.tsx ================================================ import { describe, it, expect } from "vitest"; import React from "react"; import { render, act, fireEvent, screen } from "@testing-library/react"; import type { Spec } from "@json-render/core"; import { JSONUIProvider, Renderer, type ComponentRenderProps, } from "./renderer"; import { useStateStore } from "./contexts/state"; /** * Minimal Button component that calls emit("press") on click. */ function Button({ element, emit }: ComponentRenderProps<{ label: string }>) { return ( ); } /** * Text component that renders its `text` prop. */ function Text({ element }: ComponentRenderProps<{ text: unknown }>) { const value = element.props.text; return ( {typeof value === "string" ? value : JSON.stringify(value)} ); } /** * Helper component that reads live state and exposes it via a data attribute * so we can assert against the store after actions fire. */ function StateProbe() { const { state } = useStateStore(); return

{JSON.stringify(state)}
; } const registry = { Button, Text, }; describe("chained actions: live $state resolution (#141)", () => { it("setState after pushState sees the post-push value via $state", async () => { const spec: Spec = { state: { items: ["initial"], observed: "not yet set" }, root: "main", elements: { main: { type: "Button", props: { label: "Add Item" }, on: { press: [ { action: "pushState", params: { statePath: "/items", value: "new-item" }, }, { action: "setState", params: { statePath: "/observed", value: { $state: "/items" }, }, }, ], }, }, }, }; function App() { return ( ); } render(); await act(async () => { fireEvent.click(screen.getByTestId("btn")); }); const probe = screen.getByTestId("state-probe"); const state = JSON.parse(probe.textContent!); expect(state.items).toEqual(["initial", "new-item"]); expect(state.observed).toEqual(["initial", "new-item"]); }); it("multiple pushState + setState chain resolves correctly", async () => { const spec: Spec = { state: { items: [], snapshot: null }, root: "main", elements: { main: { type: "Button", props: { label: "Go" }, on: { press: [ { action: "pushState", params: { statePath: "/items", value: "a" }, }, { action: "pushState", params: { statePath: "/items", value: "b" }, }, { action: "setState", params: { statePath: "/snapshot", value: { $state: "/items" }, }, }, ], }, }, }, }; function App() { return ( ); } render(); await act(async () => { fireEvent.click(screen.getByTestId("btn")); }); const state = JSON.parse(screen.getByTestId("state-probe").textContent!); expect(state.items).toEqual(["a", "b"]); expect(state.snapshot).toEqual(["a", "b"]); }); it("setState reading a path mutated by an earlier setState sees fresh value", async () => { const spec: Spec = { state: { counter: 0, counterCopy: -1 }, root: "main", elements: { main: { type: "Button", props: { label: "Go" }, on: { press: [ { action: "setState", params: { statePath: "/counter", value: 42 }, }, { action: "setState", params: { statePath: "/counterCopy", value: { $state: "/counter" }, }, }, ], }, }, }, }; function App() { return ( ); } render(); await act(async () => { fireEvent.click(screen.getByTestId("btn")); }); const state = JSON.parse(screen.getByTestId("state-probe").textContent!); expect(state.counter).toBe(42); expect(state.counterCopy).toBe(42); }); }); ================================================ FILE: packages/react/src/contexts/actions.tsx ================================================ "use client"; import React, { createContext, useContext, useState, useCallback, useMemo, type ReactNode, } from "react"; import { resolveAction, executeAction, type ActionBinding, type ActionHandler, type ActionConfirm, type ResolvedAction, } from "@json-render/core"; import { useStateStore } from "./state"; import { useOptionalValidation } from "./validation"; /** * Generate a unique ID for use with the "$id" token. * Combines a timestamp with a random suffix for uniqueness. */ let idCounter = 0; function generateUniqueId(): string { idCounter += 1; return `${Date.now()}-${idCounter}`; } /** * Deep-resolve dynamic value references within an object. * * Supported tokens: * - `{ $state: "/statePath" }` - read a value from state * - `"$id"` (string) or `{ "$id": true }` - generate a unique ID * * This allows pushState values to contain references to current state * and auto-generated IDs. */ function deepResolveValue( value: unknown, get: (path: string) => unknown, ): unknown { if (value === null || value === undefined) return value; // "$id" string token -> generate unique ID if (value === "$id") { return generateUniqueId(); } if (typeof value === "object" && !Array.isArray(value)) { const obj = value as Record; const keys = Object.keys(obj); // { $state: "/foo" } -> read from state if (keys.length === 1 && typeof obj.$state === "string") { return get(obj.$state as string); } // { "$id": true } -> generate unique ID (single-key object) if (keys.length === 1 && "$id" in obj) { return generateUniqueId(); } } // Recurse into arrays if (Array.isArray(value)) { return value.map((item) => deepResolveValue(item, get)); } // Recurse into plain objects if (typeof value === "object") { const resolved: Record = {}; for (const [key, val] of Object.entries(value as Record)) { resolved[key] = deepResolveValue(val, get); } return resolved; } return value; } /** * Pending confirmation state */ export interface PendingConfirmation { /** The resolved action */ action: ResolvedAction; /** The action handler */ handler: ActionHandler; /** Resolve callback */ resolve: () => void; /** Reject callback */ reject: () => void; } /** * Action context value */ export interface ActionContextValue { /** Registered action handlers */ handlers: Record; /** Currently loading action names */ loadingActions: Set; /** Pending confirmation dialog */ pendingConfirmation: PendingConfirmation | null; /** Execute an action binding */ execute: (binding: ActionBinding) => Promise; /** Confirm the pending action */ confirm: () => void; /** Cancel the pending action */ cancel: () => void; /** Register an action handler */ registerHandler: (name: string, handler: ActionHandler) => void; } const ActionContext = createContext(null); /** * Props for ActionProvider */ export interface ActionProviderProps { /** Initial action handlers */ handlers?: Record; /** Navigation function */ navigate?: (path: string) => void; children: ReactNode; } /** * Provider for action execution */ export function ActionProvider({ handlers: initialHandlers = {}, navigate, children, }: ActionProviderProps) { const { get, set, getSnapshot } = useStateStore(); const validation = useOptionalValidation(); const [handlers, setHandlers] = useState>(initialHandlers); const [loadingActions, setLoadingActions] = useState>(new Set()); const [pendingConfirmation, setPendingConfirmation] = useState(null); const registerHandler = useCallback( (name: string, handler: ActionHandler) => { setHandlers((prev) => ({ ...prev, [name]: handler })); }, [], ); const execute = useCallback( async (binding: ActionBinding) => { const resolved = resolveAction(binding, getSnapshot()); // Built-in: setState updates the StateProvider state directly if (resolved.action === "setState" && resolved.params) { const statePath = resolved.params.statePath as string; const value = resolved.params.value; if (statePath) { set(statePath, value); } return; } // Built-in: pushState appends an item to an array in state. // Supports dynamic values inside the value object via { $state: "/..." } syntax. if (resolved.action === "pushState" && resolved.params) { const statePath = resolved.params.statePath as string; const rawValue = resolved.params.value; if (statePath) { const resolvedValue = deepResolveValue(rawValue, get); const arr = (get(statePath) as unknown[] | undefined) ?? []; set(statePath, [...arr, resolvedValue]); // Optionally clear a state path after pushing (e.g. clear the input) const clearStatePath = resolved.params.clearStatePath as | string | undefined; if (clearStatePath) { set(clearStatePath, ""); } } return; } // Built-in: removeState removes an item from an array in state by index. if (resolved.action === "removeState" && resolved.params) { const statePath = resolved.params.statePath as string; const index = resolved.params.index as number; if (statePath !== undefined && index !== undefined) { const arr = (get(statePath) as unknown[] | undefined) ?? []; set( statePath, arr.filter((_, i) => i !== index), ); } return; } // Built-in: push navigates to a new screen by updating state. // Pushes the current screen onto /navStack and sets /currentScreen. if (resolved.action === "push" && resolved.params) { const screen = resolved.params.screen as string; if (screen) { const currentScreen = get("/currentScreen") as string | undefined; const navStack = (get("/navStack") as string[] | undefined) ?? []; if (currentScreen) { set("/navStack", [...navStack, currentScreen]); } else { // No current screen set yet -- push a sentinel so pop returns here set("/navStack", [...navStack, ""]); } set("/currentScreen", screen); } return; } // Built-in: pop navigates back to the previous screen. // Pops the last entry from /navStack and restores /currentScreen. if (resolved.action === "pop") { const navStack = (get("/navStack") as string[] | undefined) ?? []; if (navStack.length > 0) { const previousScreen = navStack[navStack.length - 1]; set("/navStack", navStack.slice(0, -1)); if (previousScreen) { set("/currentScreen", previousScreen); } else { set("/currentScreen", undefined); } } return; } // Built-in: validateForm triggers validateAll from the ValidationProvider // and writes the result to a state path (default: /formValidation). // IMPORTANT: validateAll() is synchronous — it runs all registered field // validations and returns immediately. This guarantees that the next action // in a sequential list (e.g. [validateForm, submitForm]) can read the // validation result from state without awaiting an extra tick. if (resolved.action === "validateForm") { const validateAll = validation?.validateAll; if (!validateAll) { console.warn( "validateForm action was dispatched but no ValidationProvider is connected. " + "Ensure ValidationProvider is rendered inside the provider tree.", ); return; } const valid = validateAll(); const errors: Record = {}; for (const [path, fs] of Object.entries(validation.fieldStates)) { if (fs.result && !fs.result.valid) { errors[path] = fs.result.errors; } } const statePath = (resolved.params?.statePath as string) || "/formValidation"; set(statePath, { valid, errors }); return; } const handler = handlers[resolved.action]; if (!handler) { console.warn(`No handler registered for action: ${resolved.action}`); return; } // If confirmation is required, show dialog if (resolved.confirm) { return new Promise((resolve, reject) => { setPendingConfirmation({ action: resolved, handler, resolve: () => { setPendingConfirmation(null); resolve(); }, reject: () => { setPendingConfirmation(null); reject(new Error("Action cancelled")); }, }); }).then(async () => { setLoadingActions((prev) => new Set(prev).add(resolved.action)); try { await executeAction({ action: resolved, handler, setState: set, navigate, executeAction: async (name) => { const subBinding: ActionBinding = { action: name }; await execute(subBinding); }, }); } finally { setLoadingActions((prev) => { const next = new Set(prev); next.delete(resolved.action); return next; }); } }); } // Execute immediately setLoadingActions((prev) => new Set(prev).add(resolved.action)); try { await executeAction({ action: resolved, handler, setState: set, navigate, executeAction: async (name) => { const subBinding: ActionBinding = { action: name }; await execute(subBinding); }, }); } finally { setLoadingActions((prev) => { const next = new Set(prev); next.delete(resolved.action); return next; }); } }, [handlers, get, set, getSnapshot, navigate, validation], ); const confirm = useCallback(() => { pendingConfirmation?.resolve(); }, [pendingConfirmation]); const cancel = useCallback(() => { pendingConfirmation?.reject(); }, [pendingConfirmation]); const value = useMemo( () => ({ handlers, loadingActions, pendingConfirmation, execute, confirm, cancel, registerHandler, }), [ handlers, loadingActions, pendingConfirmation, execute, confirm, cancel, registerHandler, ], ); return ( {children} ); } /** * Hook to access action context */ export function useActions(): ActionContextValue { const ctx = useContext(ActionContext); if (!ctx) { throw new Error("useActions must be used within an ActionProvider"); } return ctx; } /** * Hook to execute an action binding */ export function useAction(binding: ActionBinding): { execute: () => Promise; isLoading: boolean; } { const { execute, loadingActions } = useActions(); const isLoading = loadingActions.has(binding.action); const executeAction = useCallback(() => execute(binding), [execute, binding]); return { execute: executeAction, isLoading }; } /** * Props for ConfirmDialog component */ export interface ConfirmDialogProps { /** The confirmation config */ confirm: ActionConfirm; /** Called when confirmed */ onConfirm: () => void; /** Called when cancelled */ onCancel: () => void; } /** * Default confirmation dialog component */ export function ConfirmDialog({ confirm, onConfirm, onCancel, }: ConfirmDialogProps) { const isDanger = confirm.variant === "danger"; return (
e.stopPropagation()} >

{confirm.title}

{confirm.message}

); } ================================================ FILE: packages/react/src/contexts/repeat-scope.tsx ================================================ "use client"; import React, { createContext, useContext, type ReactNode } from "react"; /** * Repeat scope value provided to child elements inside a repeated element. */ export interface RepeatScopeValue { /** The current array item object */ item: unknown; /** Index of the current item in the array */ index: number; /** Absolute state path to the current array item (e.g. "/todos/0") — used for statePath two-way binding */ basePath: string; } const RepeatScopeContext = createContext(null); /** * Provides repeat scope to child elements so $item and $index expressions resolve correctly. */ export function RepeatScopeProvider({ item, index, basePath, children, }: RepeatScopeValue & { children: ReactNode }) { return ( {children} ); } /** * Read the current repeat scope (or null if not inside a repeated element). */ export function useRepeatScope(): RepeatScopeValue | null { return useContext(RepeatScopeContext); } ================================================ FILE: packages/react/src/contexts/state.test.tsx ================================================ import { describe, it, expect } from "vitest"; import React from "react"; import { renderHook, act } from "@testing-library/react"; import { StateProvider, useStateStore, useStateValue, useStateBinding, } from "./state"; describe("state re-exports (smoke test)", () => { it("StateProvider + useStateStore round-trip", () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result } = renderHook(() => useStateStore(), { wrapper }); expect(result.current.get("/count")).toBe(0); act(() => { result.current.set("/count", 42); }); expect(result.current.state.count).toBe(42); }); it("useStateValue reads from state", () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result } = renderHook(() => useStateValue("/name"), { wrapper }); expect(result.current).toBe("Alice"); }); it("useStateBinding returns value and setter", () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result } = renderHook(() => useStateBinding("/x"), { wrapper }); const [value, setValue] = result.current; expect(value).toBe(1); expect(typeof setValue).toBe("function"); }); }); ================================================ FILE: packages/react/src/contexts/state.tsx ================================================ "use client"; export { StateProvider, useStateStore, useStateValue, useStateBinding, type StateContextValue, type StateProviderProps, } from "@internal/react-state"; ================================================ FILE: packages/react/src/contexts/validation.tsx ================================================ "use client"; import React, { createContext, useContext, useRef, useState, useCallback, useMemo, type ReactNode, } from "react"; import { runValidation, type ValidationConfig, type ValidationFunction, type ValidationResult, } from "@json-render/core"; import { useStateStore } from "./state"; /** * Field validation state */ export interface FieldValidationState { /** Whether the field has been touched */ touched: boolean; /** Whether the field has been validated */ validated: boolean; /** Validation result */ result: ValidationResult | null; } /** * Validation context value */ export interface ValidationContextValue { /** Custom validation functions from catalog */ customFunctions: Record; /** Validation state by field path */ fieldStates: Record; /** Validate a field */ validate: (path: string, config: ValidationConfig) => ValidationResult; /** Mark field as touched */ touch: (path: string) => void; /** Clear validation for a field */ clear: (path: string) => void; /** Validate all fields */ validateAll: () => boolean; /** Register field config */ registerField: (path: string, config: ValidationConfig) => void; } const ValidationContext = createContext(null); /** * Props for ValidationProvider */ export interface ValidationProviderProps { /** Custom validation functions from catalog */ customFunctions?: Record; children: ReactNode; } /** * Compare two DynamicValue args records shallowly. * Values are primitives or { $state: string }, so shallow comparison suffices. */ function dynamicArgsEqual( a: Record | undefined, b: Record | undefined, ): boolean { if (a === b) return true; if (!a || !b) return false; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { const va = a[key]; const vb = b[key]; if (va === vb) continue; // Handle { $state: string } objects if ( typeof va === "object" && va !== null && typeof vb === "object" && vb !== null ) { const sa = (va as Record).$state; const sb = (vb as Record).$state; if (typeof sa === "string" && sa === sb) continue; } return false; } return true; } /** * Structural equality check for ValidationConfig. */ function validationConfigEqual( a: ValidationConfig, b: ValidationConfig, ): boolean { if (a === b) return true; // Compare validateOn if (a.validateOn !== b.validateOn) return false; // Compare checks arrays const ac = a.checks ?? []; const bc = b.checks ?? []; if (ac.length !== bc.length) return false; for (let i = 0; i < ac.length; i++) { const ca = ac[i]!; const cb = bc[i]!; if (ca.type !== cb.type) return false; if (ca.message !== cb.message) return false; if (!dynamicArgsEqual(ca.args, cb.args)) return false; } return true; } /** * Provider for validation */ export function ValidationProvider({ customFunctions = {}, children, }: ValidationProviderProps) { const { state, getSnapshot } = useStateStore(); const [fieldStates, setFieldStates] = useState< Record >({}); // Mutable mirror of fieldStates for synchronous reads (e.g. reading errors // immediately after validateAll() before React flushes the batched setState). const fieldStatesRef = useRef>({}); const [fieldConfigs, setFieldConfigs] = useState< Record >({}); const registerField = useCallback( (path: string, config: ValidationConfig) => { setFieldConfigs((prev) => { const existing = prev[path]; // Bail out (return same reference) if config is unchanged to avoid // infinite re-render loops when callers pass a fresh object each render. if (existing && validationConfigEqual(existing, config)) { return prev; } return { ...prev, [path]: config }; }); }, [], ); const validate = useCallback( (path: string, config: ValidationConfig): ValidationResult => { // Read from the store directly so validation sees values written in the // same synchronous handler (e.g. setValue then validate in onChange). // Using React state would return the stale pre-render snapshot. const currentState = getSnapshot(); const segments = path.split("/").filter(Boolean); let value: unknown = currentState; for (const seg of segments) { if (value != null && typeof value === "object") { value = (value as Record)[seg]; } else { value = undefined; break; } } const result = runValidation(config, { value, stateModel: currentState, customFunctions, }); const newFieldState: FieldValidationState = { touched: fieldStatesRef.current[path]?.touched ?? true, validated: true, result, }; fieldStatesRef.current = { ...fieldStatesRef.current, [path]: newFieldState, }; setFieldStates(fieldStatesRef.current); return result; }, [customFunctions, getSnapshot], ); const touch = useCallback((path: string) => { fieldStatesRef.current = { ...fieldStatesRef.current, [path]: { ...fieldStatesRef.current[path], touched: true, validated: fieldStatesRef.current[path]?.validated ?? false, result: fieldStatesRef.current[path]?.result ?? null, }, }; setFieldStates(fieldStatesRef.current); }, []); const clear = useCallback((path: string) => { const { [path]: _, ...rest } = fieldStatesRef.current; fieldStatesRef.current = rest; setFieldStates(rest); }, []); const validateAll = useCallback(() => { let allValid = true; for (const [path, config] of Object.entries(fieldConfigs)) { const result = validate(path, config); if (!result.valid) { allValid = false; } } return allValid; }, [fieldConfigs, validate]); const value = useMemo( () => ({ customFunctions, // Getter returns the mutable ref so callers that read fieldStates // synchronously after validateAll() see the latest values. get fieldStates() { return fieldStatesRef.current; }, validate, touch, clear, validateAll, registerField, }), [ customFunctions, // fieldStates (React state) stays in deps so the context value object // is recreated on re-render, triggering downstream consumers. fieldStates, validate, touch, clear, validateAll, registerField, ], ); return ( {children} ); } /** * Hook to access validation context */ export function useValidation(): ValidationContextValue { const ctx = useContext(ValidationContext); if (!ctx) { throw new Error("useValidation must be used within a ValidationProvider"); } return ctx; } /** * Non-throwing variant of useValidation. * Returns null when no ValidationProvider is present. */ export function useOptionalValidation(): ValidationContextValue | null { return useContext(ValidationContext); } /** * Hook to get validation state for a field */ export function useFieldValidation( path: string, config?: ValidationConfig, ): { state: FieldValidationState; validate: () => ValidationResult; touch: () => void; clear: () => void; errors: string[]; isValid: boolean; } { const { fieldStates, validate: validateField, touch: touchField, clear: clearField, registerField, } = useValidation(); // Register field on mount React.useEffect(() => { if (path && config) { registerField(path, config); } }, [path, config, registerField]); const state = fieldStates[path] ?? { touched: false, validated: false, result: null, }; const validate = useCallback( () => validateField(path, config ?? { checks: [] }), [path, config, validateField], ); const touch = useCallback(() => touchField(path), [path, touchField]); const clear = useCallback(() => clearField(path), [path, clearField]); return { state, validate, touch, clear, errors: state.result?.errors ?? [], isValid: state.result?.valid ?? true, }; } ================================================ FILE: packages/react/src/contexts/visibility.test.tsx ================================================ import { describe, it, expect } from "vitest"; import React from "react"; import { renderHook } from "@testing-library/react"; import { VisibilityProvider, useVisibility, useIsVisible } from "./visibility"; import { StateProvider } from "./state"; const createWrapper = (data: Record = {}) => ({ children }: { children: React.ReactNode }) => ( {children} ); describe("useVisibility", () => { it("provides isVisible function", () => { const { result } = renderHook(() => useVisibility(), { wrapper: createWrapper(), }); expect(typeof result.current.isVisible).toBe("function"); }); it("provides visibility context", () => { const { result } = renderHook(() => useVisibility(), { wrapper: createWrapper({ test: true }), }); expect(result.current.ctx.stateModel).toEqual({ test: true }); }); }); describe("useIsVisible", () => { it("returns true for undefined condition", () => { const { result } = renderHook(() => useIsVisible(undefined), { wrapper: createWrapper(), }); expect(result.current).toBe(true); }); it("returns true for true condition", () => { const { result } = renderHook(() => useIsVisible(true), { wrapper: createWrapper(), }); expect(result.current).toBe(true); }); it("returns false for false condition", () => { const { result } = renderHook(() => useIsVisible(false), { wrapper: createWrapper(), }); expect(result.current).toBe(false); }); it("evaluates $state conditions against data", () => { const { result: trueResult } = renderHook( () => useIsVisible({ $state: "/isVisible" }), { wrapper: createWrapper({ isVisible: true }) }, ); expect(trueResult.current).toBe(true); const { result: falseResult } = renderHook( () => useIsVisible({ $state: "/isVisible" }), { wrapper: createWrapper({ isVisible: false }) }, ); expect(falseResult.current).toBe(false); }); it("evaluates equality conditions", () => { const { result } = renderHook( () => useIsVisible({ $state: "/count", eq: 1 }), { wrapper: createWrapper({ count: 1 }) }, ); expect(result.current).toBe(true); }); it("evaluates array conditions (implicit AND)", () => { const { result } = renderHook( () => useIsVisible([ { $state: "/user/isAdmin" }, { $state: "/count", eq: 5 }, ]), { wrapper: createWrapper({ user: { isAdmin: true }, count: 5 }) }, ); expect(result.current).toBe(true); }); }); ================================================ FILE: packages/react/src/contexts/visibility.tsx ================================================ "use client"; import React, { createContext, useContext, useMemo, type ReactNode, } from "react"; import { evaluateVisibility, type VisibilityCondition, type VisibilityContext as CoreVisibilityContext, } from "@json-render/core"; import { useStateStore } from "./state"; /** * Visibility context value */ export interface VisibilityContextValue { /** Evaluate a visibility condition */ isVisible: (condition: VisibilityCondition | undefined) => boolean; /** The underlying visibility context */ ctx: CoreVisibilityContext; } const VisibilityContext = createContext(null); /** * Props for VisibilityProvider */ export interface VisibilityProviderProps { children: ReactNode; } /** * Provider for visibility evaluation */ export function VisibilityProvider({ children }: VisibilityProviderProps) { const { state } = useStateStore(); const ctx: CoreVisibilityContext = useMemo( () => ({ stateModel: state, }), [state], ); const isVisible = useMemo( () => (condition: VisibilityCondition | undefined) => evaluateVisibility(condition, ctx), [ctx], ); const value = useMemo( () => ({ isVisible, ctx }), [isVisible, ctx], ); return ( {children} ); } /** * Hook to access visibility evaluation */ export function useVisibility(): VisibilityContextValue { const ctx = useContext(VisibilityContext); if (!ctx) { throw new Error("useVisibility must be used within a VisibilityProvider"); } return ctx; } /** * Hook to check if a condition is visible */ export function useIsVisible( condition: VisibilityCondition | undefined, ): boolean { const { isVisible } = useVisibility(); return isVisible(condition); } ================================================ FILE: packages/react/src/dynamic-forms.test.tsx ================================================ import { describe, it, expect, vi } from "vitest"; import React, { useState, useCallback, useMemo } from "react"; import { render, act, fireEvent, screen } from "@testing-library/react"; import type { Spec } from "@json-render/core"; import { JSONUIProvider, Renderer, type ComponentRenderProps, } from "./renderer"; import { useStateStore } from "./contexts/state"; import { useFieldValidation } from "./contexts/validation"; import { useBoundProp } from "./hooks"; // ============================================================================= // Stub components // ============================================================================= function Button({ element, emit }: ComponentRenderProps<{ label: string }>) { return ( ); } function Text({ element }: ComponentRenderProps<{ text: unknown }>) { const value = element.props.text; return ( {value == null ? "" : typeof value === "string" ? value : JSON.stringify(value)} ); } function InputField({ element, bindings, }: ComponentRenderProps<{ label?: string; value?: string; checks?: Array<{ type: string; message: string; args?: Record; }>; }>) { const props = element.props; const [boundValue, setBoundValue] = useBoundProp( props.value as string | undefined, bindings?.value, ); const [localValue, setLocalValue] = useState(""); const isBound = !!bindings?.value; const value = isBound ? (boundValue ?? "") : localValue; const setValue = isBound ? setBoundValue : setLocalValue; const hasValidation = !!(bindings?.value && props.checks?.length); const config = useMemo( () => (hasValidation ? { checks: props.checks ?? [] } : undefined), [hasValidation, props.checks], ); const { errors } = useFieldValidation(bindings?.value ?? "", config); return (
{props.label && } setValue(e.target.value)} /> {errors.length > 0 && {errors[0]}}
); } function SelectField({ element, bindings, }: ComponentRenderProps<{ label?: string; value?: string }>) { const props = element.props; const [boundValue] = useBoundProp( props.value as string | undefined, bindings?.value, ); return {boundValue ?? ""}; } /** * Select stub that mirrors the real shadcn Select validation behavior: * calls setValue then validate() synchronously in onValueChange. */ function ValidatedSelect({ element, bindings, emit, }: ComponentRenderProps<{ label?: string; name?: string; options?: string[]; placeholder?: string; value?: string; checks?: Array<{ type: string; message: string; args?: Record; }>; validateOn?: "change" | "blur" | "submit"; }>) { const props = element.props; const [boundValue, setBoundValue] = useBoundProp( props.value as string | undefined, bindings?.value, ); const [localValue, setLocalValue] = useState(""); const isBound = !!bindings?.value; const value = isBound ? (boundValue ?? "") : localValue; const setValue = isBound ? setBoundValue : setLocalValue; const validateOn = props.validateOn ?? "change"; const hasValidation = !!(bindings?.value && props.checks?.length); const config = useMemo( () => hasValidation ? { checks: props.checks ?? [], validateOn } : undefined, [hasValidation, props.checks, validateOn], ); const { errors, validate } = useFieldValidation( bindings?.value ?? "", config, ); const options = props.options ?? []; return (
{props.label && } {errors.length > 0 && ( {errors[0]} )}
); } function StateProbe() { const { state } = useStateStore(); return
{JSON.stringify(state)}
; } const registry = { Button, Text, Input: InputField, Select: SelectField }; function getState(): Record { return JSON.parse(screen.getByTestId("state-probe").textContent!); } // ============================================================================= // $computed expressions in rendering // ============================================================================= describe("$computed expressions in rendering", () => { it("resolves a $computed prop using provided functions", async () => { const spec: Spec = { state: { first: "Jane", last: "Doe" }, root: "main", elements: { main: { type: "Text", props: { text: { $computed: "fullName", args: { first: { $state: "/first" }, last: { $state: "/last" }, }, }, }, children: [], }, }, }; const functions = { fullName: (args: Record) => `${args.first} ${args.last}`, }; render( , ); expect(screen.getByTestId("text").textContent).toBe("Jane Doe"); }); it("renders gracefully when functions prop is omitted", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const spec: Spec = { state: {}, root: "main", elements: { main: { type: "Text", props: { text: { $computed: "missing" }, }, children: [], }, }, }; render( , ); expect(screen.getByTestId("text").textContent).toBe(""); warnSpy.mockRestore(); }); }); // ============================================================================= // $template expressions in rendering // ============================================================================= describe("$template expressions in rendering", () => { it("interpolates state values into a template string", () => { const spec: Spec = { state: { user: { name: "Alice" }, count: 3 }, root: "main", elements: { main: { type: "Text", props: { text: { $template: "Hello, ${/user/name}! You have ${/count} messages.", }, }, children: [], }, }, }; render( , ); expect(screen.getByTestId("text").textContent).toBe( "Hello, Alice! You have 3 messages.", ); }); it("resolves missing paths to empty string", () => { const spec: Spec = { state: {}, root: "main", elements: { main: { type: "Text", props: { text: { $template: "Hi ${/name}!" }, }, children: [], }, }, }; render( , ); expect(screen.getByTestId("text").textContent).toBe("Hi !"); }); }); // ============================================================================= // Watchers // ============================================================================= describe("watchers (watch field)", () => { it("does not fire on initial render, fires when watched state changes", async () => { const loadCities = vi.fn(async (params: Record) => { // no-op, just tracking the call }); const spec: Spec = { state: { form: { country: "" }, citiesLoaded: false }, root: "main", elements: { main: { type: "Button", props: { label: "Set Country" }, on: { press: [ { action: "setState", params: { statePath: "/form/country", value: "US" }, }, ], }, children: [], }, watcher: { type: "Select", props: { value: { $state: "/form/country" } }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } }, }, }, children: [], }, }, }; // Add watcher as a child of a wrapper so both render const wrapperSpec: Spec = { ...spec, root: "wrapper", elements: { ...spec.elements, wrapper: { type: "Button", props: { label: "wrapper" }, children: ["main", "watcher"], }, }, }; // Use a Stack-like wrapper -- but since we only have Button/Text/Select // stubs, we need a container. Let's add a simple Stack stub. const Stack = ({ children, }: ComponentRenderProps>) => { return
{children}
; }; const reg = { ...registry, Stack }; const stackSpec: Spec = { state: { form: { country: "" }, citiesLoaded: false }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["btn", "watcher"], }, btn: { type: "Button", props: { label: "Set Country" }, on: { press: [ { action: "setState", params: { statePath: "/form/country", value: "US" }, }, ], }, children: [], }, watcher: { type: "Select", props: { value: { $state: "/form/country" } }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } }, }, }, children: [], }, }, }; render( , ); // Not called on initial render expect(loadCities).not.toHaveBeenCalled(); // Change the watched state path await act(async () => { fireEvent.click(screen.getByTestId("btn")); }); expect(loadCities).toHaveBeenCalledTimes(1); expect(loadCities).toHaveBeenCalledWith( expect.objectContaining({ country: "US" }), ); }); it("fires multiple action bindings on the same watch path", async () => { const action1 = vi.fn(); const action2 = vi.fn(); const Stack = ({ children, }: ComponentRenderProps>) =>
{children}
; const reg = { ...registry, Stack }; const spec: Spec = { state: { value: "a" }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["btn", "watcher"], }, btn: { type: "Button", props: { label: "Change" }, on: { press: [ { action: "setState", params: { statePath: "/value", value: "b" }, }, ], }, children: [], }, watcher: { type: "Text", props: { text: { $state: "/value" } }, watch: { "/value": [{ action: "action1" }, { action: "action2" }], }, children: [], }, }, }; render( , ); await act(async () => { fireEvent.click(screen.getByTestId("btn")); }); expect(action1).toHaveBeenCalledTimes(1); expect(action2).toHaveBeenCalledTimes(1); }); }); // ============================================================================= // validateForm action // ============================================================================= describe("validateForm action", () => { it("writes { valid: false } when a required field is empty", async () => { const Stack = ({ children, }: ComponentRenderProps>) =>
{children}
; const reg = { ...registry, Stack }; const spec: Spec = { state: { form: { email: "" }, result: null }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["emailInput", "submitBtn"], }, emailInput: { type: "Input", props: { label: "Email", value: { $bindState: "/form/email" }, checks: [{ type: "required", message: "Email is required" }], }, children: [], }, submitBtn: { type: "Button", props: { label: "Submit" }, on: { press: [ { action: "validateForm", params: { statePath: "/result" }, }, ], }, children: [], }, }, }; render( , ); // Click submit with empty email await act(async () => { fireEvent.click(screen.getByTestId("btn")); }); const state = getState(); expect(state.result).toEqual({ valid: false, errors: { "/form/email": ["Email is required"] }, }); }); it("writes { valid: true } when all fields pass validation", async () => { const Stack = ({ children, }: ComponentRenderProps>) =>
{children}
; const reg = { ...registry, Stack }; const spec: Spec = { state: { form: { email: "test@example.com" }, result: null }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["emailInput", "submitBtn"], }, emailInput: { type: "Input", props: { label: "Email", value: { $bindState: "/form/email" }, checks: [{ type: "required", message: "Email is required" }], }, children: [], }, submitBtn: { type: "Button", props: { label: "Submit" }, on: { press: [ { action: "validateForm", params: { statePath: "/result" }, }, ], }, children: [], }, }, }; render( , ); await act(async () => { fireEvent.click(screen.getByTestId("btn")); }); const state = getState(); expect(state.result).toEqual({ valid: true, errors: {} }); }); it("defaults to /formValidation when no statePath is provided", async () => { const Stack = ({ children, }: ComponentRenderProps>) =>
{children}
; const reg = { ...registry, Stack }; const spec: Spec = { state: { form: { name: "filled" } }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["nameInput", "submitBtn"], }, nameInput: { type: "Input", props: { label: "Name", value: { $bindState: "/form/name" }, checks: [{ type: "required", message: "Required" }], }, children: [], }, submitBtn: { type: "Button", props: { label: "Submit" }, on: { press: [{ action: "validateForm" }], }, children: [], }, }, }; render( , ); await act(async () => { fireEvent.click(screen.getByTestId("btn")); }); const state = getState(); expect(state.formValidation).toEqual({ valid: true, errors: {} }); }); }); // ============================================================================= // Select validate-on-change timing (#151) // ============================================================================= describe("Select validate-on-change sees the new value, not the stale value", () => { const Stack = ({ children, }: ComponentRenderProps>) =>
{children}
; const regWithSelect = { ...registry, Stack, Select: ValidatedSelect, }; it("does not show 'required' error when selecting the first value", async () => { const spec: Spec = { state: { form: { country: "" } }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["countrySelect"], }, countrySelect: { type: "Select", props: { label: "Country", name: "country", options: ["US", "Canada", "UK"], placeholder: "Choose a country", value: { $bindState: "/form/country" }, checks: [{ type: "required", message: "Country is required" }], validateOn: "change", }, children: [], }, }, }; render( , ); // Select "US" for the first time (from empty) await act(async () => { fireEvent.change(screen.getByTestId("select-country"), { target: { value: "US" }, }); }); // The value should be set in state const state = getState(); expect((state.form as Record).country).toBe("US"); // No validation error should appear -- "US" is non-empty expect(screen.queryByTestId("select-error-country")).toBeNull(); }); it("does not show 'required' error when selecting the first city after country change resets it", async () => { const spec: Spec = { state: { form: { country: "US", city: "" }, availableCities: ["New York", "Chicago"], }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["citySelect"], }, citySelect: { type: "Select", props: { label: "City", name: "city", options: ["New York", "Chicago"], placeholder: "Select a city", value: { $bindState: "/form/city" }, checks: [{ type: "required", message: "City is required" }], validateOn: "change", }, children: [], }, }, }; render( , ); // Select "New York" for the first time (from empty) await act(async () => { fireEvent.change(screen.getByTestId("select-city"), { target: { value: "New York" }, }); }); const state = getState(); expect((state.form as Record).city).toBe("New York"); // No validation error should appear expect(screen.queryByTestId("select-error-city")).toBeNull(); }); }); ================================================ FILE: packages/react/src/hooks.test.ts ================================================ import { describe, it, expect } from "vitest"; import { flatToTree, buildSpecFromParts, getTextFromParts } from "./hooks"; describe("flatToTree", () => { it("converts array of elements to tree structure", () => { const elements = [ { key: "container", type: "stack", props: {}, parentKey: null }, { key: "text1", type: "text", props: { content: "Hello" }, parentKey: "container", }, { key: "text2", type: "text", props: { content: "World" }, parentKey: "container", }, ]; const tree = flatToTree(elements); expect(tree.root).toBe("container"); expect(Object.keys(tree.elements)).toHaveLength(3); expect(tree.elements["container"]).toBeDefined(); expect(tree.elements["text1"]).toBeDefined(); expect(tree.elements["text2"]).toBeDefined(); }); it("builds parent-child relationships", () => { const elements = [ { key: "root", type: "stack", props: {}, parentKey: null }, { key: "child1", type: "text", props: {}, parentKey: "root" }, { key: "child2", type: "text", props: {}, parentKey: "root" }, ]; const tree = flatToTree(elements); expect(tree.elements["root"].children).toHaveLength(2); expect(tree.elements["root"].children).toContain("child1"); expect(tree.elements["root"].children).toContain("child2"); }); it("handles single root element", () => { const elements = [ { key: "only", type: "text", props: { content: "Single" }, parentKey: null, }, ]; const tree = flatToTree(elements); expect(tree.root).toBe("only"); expect(Object.keys(tree.elements)).toHaveLength(1); }); it("handles deeply nested elements", () => { const elements = [ { key: "level0", type: "stack", props: {}, parentKey: null }, { key: "level1", type: "stack", props: {}, parentKey: "level0" }, { key: "level2", type: "stack", props: {}, parentKey: "level1" }, { key: "level3", type: "text", props: {}, parentKey: "level2" }, ]; const tree = flatToTree(elements); expect(tree.root).toBe("level0"); expect(tree.elements["level0"].children).toContain("level1"); expect(tree.elements["level1"].children).toContain("level2"); expect(tree.elements["level2"].children).toContain("level3"); }); it("preserves element props", () => { const elements = [ { key: "btn", type: "button", props: { label: "Click me", variant: "primary" }, parentKey: null, }, ]; const tree = flatToTree(elements); expect(tree.elements["btn"].props).toEqual({ label: "Click me", variant: "primary", }); }); it("preserves visibility conditions", () => { const elements = [ { key: "conditional", type: "text", props: {}, parentKey: null, visible: { $state: "/isVisible" }, }, ]; const tree = flatToTree(elements); expect(tree.elements["conditional"].visible).toEqual({ $state: "/isVisible", }); }); it("handles elements with undefined parentKey as root", () => { const elements = [ { key: "root", type: "stack", props: {} } as { key: string; type: string; props: Record; parentKey?: string | null; }, ]; const tree = flatToTree(elements); // Elements without parentKey should not become root (only null parentKey) // This tests the edge case expect(tree.elements["root"]).toBeDefined(); }); it("handles empty elements array", () => { const tree = flatToTree([]); expect(tree.root).toBe(""); expect(Object.keys(tree.elements)).toHaveLength(0); }); it("handles multiple children correctly", () => { const elements = [ { key: "parent", type: "grid", props: {}, parentKey: null }, { key: "a", type: "card", props: {}, parentKey: "parent" }, { key: "b", type: "card", props: {}, parentKey: "parent" }, { key: "c", type: "card", props: {}, parentKey: "parent" }, { key: "d", type: "card", props: {}, parentKey: "parent" }, ]; const tree = flatToTree(elements); expect(tree.elements["parent"].children).toHaveLength(4); expect(tree.elements["parent"].children).toEqual(["a", "b", "c", "d"]); }); }); // ============================================================================= // buildSpecFromParts // ============================================================================= describe("buildSpecFromParts", () => { it("returns null when no data-spec parts are present", () => { const parts = [ { type: "text", text: "Hello there" }, { type: "text", text: "How can I help?" }, ]; expect(buildSpecFromParts(parts)).toBeNull(); }); it("builds a spec from patch parts", () => { const parts = [ { type: "data-spec", data: { type: "patch", patch: { op: "add", path: "/root", value: "main" }, }, }, { type: "data-spec", data: { type: "patch", patch: { op: "add", path: "/elements/main", value: { type: "Card", props: { title: "Hello" }, children: [] }, }, }, }, ]; const spec = buildSpecFromParts(parts); expect(spec).not.toBeNull(); expect(spec!.root).toBe("main"); expect(spec!.elements.main).toEqual({ type: "Card", props: { title: "Hello" }, children: [], }); }); it("handles flat spec parts", () => { const parts = [ { type: "data-spec", data: { type: "flat", spec: { root: "card-1", elements: { "card-1": { type: "Card", props: {}, children: [] }, }, }, }, }, ]; const spec = buildSpecFromParts(parts); expect(spec).not.toBeNull(); expect(spec!.root).toBe("card-1"); expect(spec!.elements["card-1"]).toBeDefined(); }); it("ignores non-spec parts", () => { const parts = [ { type: "text", text: "Some text" }, { type: "data-spec", data: { type: "patch", patch: { op: "add", path: "/root", value: "main" }, }, }, { type: "tool-invocation", data: { toolName: "search" } }, ]; const spec = buildSpecFromParts(parts); expect(spec).not.toBeNull(); expect(spec!.root).toBe("main"); }); it("applies patches incrementally", () => { const parts = [ { type: "data-spec", data: { type: "patch", patch: { op: "add", path: "/root", value: "main" }, }, }, { type: "data-spec", data: { type: "patch", patch: { op: "add", path: "/elements/main", value: { type: "Stack", props: {}, children: ["child"] }, }, }, }, { type: "data-spec", data: { type: "patch", patch: { op: "add", path: "/elements/child", value: { type: "Text", props: { content: "Hi" }, children: [] }, }, }, }, ]; const spec = buildSpecFromParts(parts); expect(spec).not.toBeNull(); expect(Object.keys(spec!.elements)).toHaveLength(2); expect(spec!.elements.child!.props.content).toBe("Hi"); }); it("handles nested spec parts via nestedToFlat", () => { const parts = [ { type: "data-spec", data: { type: "nested", spec: { type: "Card", props: { title: "Nested" }, children: [ { type: "Text", props: { content: "Child" }, children: [] }, ], }, }, }, ]; const spec = buildSpecFromParts(parts); expect(spec).not.toBeNull(); expect(spec!.root).toBeTruthy(); // nestedToFlat generates keys like el-0, el-1 const elementKeys = Object.keys(spec!.elements); expect(elementKeys.length).toBe(2); const rootEl = spec!.elements[spec!.root]; expect(rootEl).toBeDefined(); expect(rootEl!.type).toBe("Card"); expect(rootEl!.props.title).toBe("Nested"); expect(rootEl!.children).toHaveLength(1); const childKey = rootEl!.children[0]!; const childEl = spec!.elements[childKey]; expect(childEl).toBeDefined(); expect(childEl!.type).toBe("Text"); expect(childEl!.props.content).toBe("Child"); }); it("handles mixed patch + flat + nested parts in sequence", () => { const parts = [ // Start with a patch { type: "data-spec", data: { type: "patch", patch: { op: "add", path: "/root", value: "main" }, }, }, { type: "data-spec", data: { type: "patch", patch: { op: "add", path: "/elements/main", value: { type: "Stack", props: {}, children: [] }, }, }, }, // Then a flat spec overwrites everything { type: "data-spec", data: { type: "flat", spec: { root: "card-1", elements: { "card-1": { type: "Card", props: { title: "Flat" }, children: [], }, }, }, }, }, ]; const spec = buildSpecFromParts(parts); expect(spec).not.toBeNull(); // Flat overwrites root and elements expect(spec!.root).toBe("card-1"); expect(spec!.elements["card-1"]).toBeDefined(); expect(spec!.elements["card-1"]!.type).toBe("Card"); }); it("returns empty elements map from empty parts list", () => { const spec = buildSpecFromParts([]); expect(spec).toBeNull(); }); }); // ============================================================================= // getTextFromParts // ============================================================================= describe("getTextFromParts", () => { it("extracts text from text parts", () => { const parts = [ { type: "text", text: "Hello" }, { type: "text", text: "World" }, ]; expect(getTextFromParts(parts)).toBe("Hello\n\nWorld"); }); it("returns empty string when no text parts", () => { const parts = [ { type: "data-spec", data: { type: "patch", patch: { op: "add", path: "/root", value: "x" }, }, }, ]; expect(getTextFromParts(parts)).toBe(""); }); it("ignores non-text parts", () => { const parts = [ { type: "text", text: "Before" }, { type: "data-spec", data: {} }, { type: "tool-invocation", data: {} }, { type: "text", text: "After" }, ]; expect(getTextFromParts(parts)).toBe("Before\n\nAfter"); }); it("trims whitespace from text parts", () => { const parts = [ { type: "text", text: " Hello " }, { type: "text", text: " World " }, ]; expect(getTextFromParts(parts)).toBe("Hello\n\nWorld"); }); it("skips empty text parts", () => { const parts = [ { type: "text", text: "Hello" }, { type: "text", text: " " }, { type: "text", text: "World" }, ]; expect(getTextFromParts(parts)).toBe("Hello\n\nWorld"); }); it("ignores text parts with non-string text field", () => { const parts = [ { type: "text", text: "Valid" }, { type: "text", text: undefined as unknown as string }, { type: "text", text: 42 as unknown as string }, { type: "text", text: "Also valid" }, ]; expect(getTextFromParts(parts)).toBe("Valid\n\nAlso valid"); }); }); ================================================ FILE: packages/react/src/hooks.ts ================================================ "use client"; import { useState, useCallback, useRef, useEffect } from "react"; import type { Spec, UIElement, FlatElement, JsonPatch, SpecDataPart, } from "@json-render/core"; import { setByPath, getByPath, addByPath, removeByPath, createMixedStreamParser, applySpecPatch, nestedToFlat, SPEC_DATA_PART_TYPE, } from "@json-render/core"; /** * Token usage metadata from AI generation */ export interface TokenUsage { promptTokens: number; completionTokens: number; totalTokens: number; } /** * Parse result for a single line -- either a patch or usage metadata */ type ParsedLine = | { type: "patch"; patch: JsonPatch } | { type: "usage"; usage: TokenUsage } | null; /** * Parse a single JSON line (patch or metadata) */ function parseLine(line: string): ParsedLine { try { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("//")) { return null; } const parsed = JSON.parse(trimmed); // Check for usage metadata if (parsed.__meta === "usage") { return { type: "usage", usage: { promptTokens: parsed.promptTokens ?? 0, completionTokens: parsed.completionTokens ?? 0, totalTokens: parsed.totalTokens ?? 0, }, }; } return { type: "patch", patch: parsed as JsonPatch }; } catch { return null; } } /** * Set a value at a spec path (for add/replace operations). */ function setSpecValue(newSpec: Spec, path: string, value: unknown): void { if (path === "/root") { newSpec.root = value as string; return; } if (path === "/state") { newSpec.state = value as Record; return; } if (path.startsWith("/state/")) { if (!newSpec.state) newSpec.state = {}; const statePath = path.slice("/state".length); // e.g. "/posts" setByPath(newSpec.state as Record, statePath, value); return; } if (path.startsWith("/elements/")) { const pathParts = path.slice("/elements/".length).split("/"); const elementKey = pathParts[0]; if (!elementKey) return; if (pathParts.length === 1) { newSpec.elements[elementKey] = value as UIElement; } else { const element = newSpec.elements[elementKey]; if (element) { const propPath = "/" + pathParts.slice(1).join("/"); const newElement = { ...element }; setByPath( newElement as unknown as Record, propPath, value, ); newSpec.elements[elementKey] = newElement; } } } } /** * Remove a value at a spec path. */ function removeSpecValue(newSpec: Spec, path: string): void { if (path === "/state") { delete newSpec.state; return; } if (path.startsWith("/state/") && newSpec.state) { const statePath = path.slice("/state".length); removeByPath(newSpec.state as Record, statePath); return; } if (path.startsWith("/elements/")) { const pathParts = path.slice("/elements/".length).split("/"); const elementKey = pathParts[0]; if (!elementKey) return; if (pathParts.length === 1) { const { [elementKey]: _, ...rest } = newSpec.elements; newSpec.elements = rest; } else { const element = newSpec.elements[elementKey]; if (element) { const propPath = "/" + pathParts.slice(1).join("/"); const newElement = { ...element }; removeByPath( newElement as unknown as Record, propPath, ); newSpec.elements[elementKey] = newElement; } } } } /** * Get a value at a spec path. */ function getSpecValue(spec: Spec, path: string): unknown { if (path === "/root") return spec.root; if (path === "/state") return spec.state; if (path.startsWith("/state/") && spec.state) { const statePath = path.slice("/state".length); return getByPath(spec.state as Record, statePath); } return getByPath(spec as unknown as Record, path); } /** * Apply an RFC 6902 JSON patch to the current spec. * Supports add, remove, replace, move, copy, and test operations. */ function applyPatch(spec: Spec, patch: JsonPatch): Spec { const newSpec = { ...spec, elements: { ...spec.elements }, ...(spec.state ? { state: { ...spec.state } } : {}), }; switch (patch.op) { case "add": case "replace": { setSpecValue(newSpec, patch.path, patch.value); break; } case "remove": { removeSpecValue(newSpec, patch.path); break; } case "move": { if (!patch.from) break; const moveValue = getSpecValue(newSpec, patch.from); removeSpecValue(newSpec, patch.from); setSpecValue(newSpec, patch.path, moveValue); break; } case "copy": { if (!patch.from) break; const copyValue = getSpecValue(newSpec, patch.from); setSpecValue(newSpec, patch.path, copyValue); break; } case "test": { // test is a no-op for rendering purposes (validation only) break; } } return newSpec; } /** * Options for useUIStream */ export interface UseUIStreamOptions { /** API endpoint */ api: string; /** Callback when complete */ onComplete?: (spec: Spec) => void; /** Callback on error */ onError?: (error: Error) => void; } /** * Return type for useUIStream */ export interface UseUIStreamReturn { /** Current UI spec */ spec: Spec | null; /** Whether currently streaming */ isStreaming: boolean; /** Error if any */ error: Error | null; /** Token usage from the last generation */ usage: TokenUsage | null; /** Raw JSONL lines received from the stream (JSON patch lines) */ rawLines: string[]; /** Send a prompt to generate UI */ send: (prompt: string, context?: Record) => Promise; /** Clear the current spec */ clear: () => void; } /** * Hook for streaming UI generation */ export function useUIStream({ api, onComplete, onError, }: UseUIStreamOptions): UseUIStreamReturn { const [spec, setSpec] = useState(null); const [isStreaming, setIsStreaming] = useState(false); const [error, setError] = useState(null); const [usage, setUsage] = useState(null); const [rawLines, setRawLines] = useState([]); const abortControllerRef = useRef(null); // Keep refs to callbacks so `send` doesn't recreate when consumers // pass inline arrow functions. const onCompleteRef = useRef(onComplete); onCompleteRef.current = onComplete; const onErrorRef = useRef(onError); onErrorRef.current = onError; const clear = useCallback(() => { setSpec(null); setError(null); }, []); const send = useCallback( async (prompt: string, context?: Record) => { // Abort any existing request abortControllerRef.current?.abort(); abortControllerRef.current = new AbortController(); setIsStreaming(true); setError(null); setUsage(null); setRawLines([]); // Start with previous spec if provided, otherwise empty spec const previousSpec = context?.previousSpec as Spec | undefined; let currentSpec: Spec = previousSpec && previousSpec.root ? { ...previousSpec, elements: { ...previousSpec.elements } } : { root: "", elements: {} }; setSpec(currentSpec); try { const response = await fetch(api, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, context, currentSpec, }), signal: abortControllerRef.current.signal, }); if (!response.ok) { // Try to parse JSON error response for better error messages let errorMessage = `HTTP error: ${response.status}`; try { const errorData = await response.json(); if (errorData.message) { errorMessage = errorData.message; } else if (errorData.error) { errorMessage = errorData.error; } } catch { // Ignore JSON parsing errors, use default message } throw new Error(errorMessage); } const reader = response.body?.getReader(); if (!reader) { throw new Error("No response body"); } const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Process complete lines const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; const result = parseLine(trimmed); if (!result) continue; if (result.type === "usage") { setUsage(result.usage); } else { setRawLines((prev) => [...prev, trimmed]); currentSpec = applyPatch(currentSpec, result.patch); setSpec({ ...currentSpec }); } } } // Process any remaining buffer if (buffer.trim()) { const trimmed = buffer.trim(); const result = parseLine(trimmed); if (result) { if (result.type === "usage") { setUsage(result.usage); } else { setRawLines((prev) => [...prev, trimmed]); currentSpec = applyPatch(currentSpec, result.patch); setSpec({ ...currentSpec }); } } } onCompleteRef.current?.(currentSpec); } catch (err) { if ((err as Error).name === "AbortError") { return; } const error = err instanceof Error ? err : new Error(String(err)); setError(error); onErrorRef.current?.(error); } finally { setIsStreaming(false); } }, [api], ); // Cleanup on unmount useEffect(() => { return () => { abortControllerRef.current?.abort(); }; }, []); return { spec, isStreaming, error, usage, rawLines, send, clear, }; } /** * Convert a flat element list to a Spec. * Input elements use key/parentKey to establish identity and relationships. * Output spec uses the map-based format where key is the map entry key * and parent-child relationships are expressed through children arrays. */ export function flatToTree(elements: FlatElement[]): Spec { const elementMap: Record = {}; let root = ""; // First pass: add all elements to map for (const element of elements) { elementMap[element.key] = { type: element.type, props: element.props, children: [], visible: element.visible, }; } // Second pass: build parent-child relationships for (const element of elements) { if (element.parentKey) { const parent = elementMap[element.parentKey]; if (parent) { if (!parent.children) { parent.children = []; } parent.children.push(element.key); } } else { root = element.key; } } return { root, elements: elementMap }; } // ============================================================================= // useBoundProp — Two-way binding helper for $bindState/$bindItem expressions // ============================================================================= /** * Hook for two-way bound props. Returns `[value, setValue]` where: * * - `value` is the already-resolved prop value (passed through from render props) * - `setValue` writes back to the bound state path (no-op if not bound) * * Designed to work with the `bindings` map that the renderer provides when * a prop uses `{ $bindState: "/path" }` or `{ $bindItem: "field" }`. * * @example * ```tsx * import { useBoundProp } from "@json-render/react"; * * const Input: ComponentRenderer = ({ props, bindings }) => { * const [value, setValue] = useBoundProp(props.value, bindings?.value); * return setValue(e.target.value)} />; * }; * ``` */ export function useBoundProp( propValue: T | undefined, bindingPath: string | undefined, ): [T | undefined, (value: T) => void] { // Import useStateStore lazily to avoid circular dependency issues. // The hook is always called inside a StateProvider so this is safe. const { set } = useStateStoreFromContext(); const setValue = useCallback( (value: T) => { if (bindingPath) set(bindingPath, value); }, [bindingPath, set], ); return [propValue, setValue]; } // Re-export useStateStore access for useBoundProp without circular import import { useStateStore as useStateStoreFromContext } from "./contexts/state"; // ============================================================================= // buildSpecFromParts — Derive Spec from AI SDK data parts // ============================================================================= /** * A single part from the AI SDK's `message.parts` array. This is a minimal * structural type so that library helpers do not depend on the AI SDK. * Fields are optional because different part types carry different data: * - Text parts have `text` * - Data parts have `data` */ export interface DataPart { type: string; text?: string; data?: unknown; } /** * Build a `Spec` by replaying all spec data parts from a message's * parts array. Returns `null` if no spec data parts are present. * * This function is designed to work with the AI SDK's `UIMessage.parts` array. * It picks out parts whose `type` is {@link SPEC_DATA_PART_TYPE} and processes them based * on the payload's `type` discriminator: * * - `"patch"`: Applies the JSON Patch operation incrementally via `applySpecPatch`. * - `"flat"`: Replaces the spec with the complete flat spec. * - `"nested"`: Assigns the nested spec directly (future: nested-to-flat conversion). * * The function has no AI SDK dependency — it operates on a generic array of * `{ type: string; data: unknown }` objects. * * @example * ```tsx * const spec = buildSpecFromParts(message.parts); * if (spec) { * return ; * } * ``` */ /** * Type guard that validates a data part payload looks like a valid * {@link SpecDataPart} before we cast it. Returns `false` (and the * part is silently skipped) for malformed payloads. */ function isSpecDataPart(data: unknown): data is SpecDataPart { if (typeof data !== "object" || data === null) return false; const obj = data as Record; switch (obj.type) { case "patch": return typeof obj.patch === "object" && obj.patch !== null; case "flat": case "nested": return typeof obj.spec === "object" && obj.spec !== null; default: return false; } } export function buildSpecFromParts(parts: DataPart[]): Spec | null { const spec: Spec = { root: "", elements: {} }; let hasSpec = false; for (const part of parts) { if (part.type === SPEC_DATA_PART_TYPE) { if (!isSpecDataPart(part.data)) continue; const payload = part.data; if (payload.type === "patch") { hasSpec = true; applySpecPatch(spec, payload.patch); } else if (payload.type === "flat") { hasSpec = true; Object.assign(spec, payload.spec); } else if (payload.type === "nested") { hasSpec = true; const flat = nestedToFlat(payload.spec); Object.assign(spec, flat); } } } return hasSpec ? spec : null; } /** * Extract and join all text content from a message's parts array. * * Filters for parts with `type === "text"`, trims each one, and joins them * with double newlines so that text from separate agent steps renders as * distinct paragraphs in markdown. * * Has no AI SDK dependency — operates on a generic `DataPart[]`. * * @example * ```tsx * const text = getTextFromParts(message.parts); * if (text) { * return {text}; * } * ``` */ export function getTextFromParts(parts: DataPart[]): string { return parts .filter( (p): p is DataPart & { text: string } => p.type === "text" && typeof p.text === "string", ) .map((p) => p.text.trim()) .filter(Boolean) .join("\n\n"); } // ============================================================================= // useJsonRenderMessage — extract spec + text from message parts // ============================================================================= /** * Hook that extracts both the json-render spec and text content from a * message's parts array. Combines `buildSpecFromParts` and `getTextFromParts` * into a single call with memoized results. * * **Memoization behavior:** Results are recomputed only when the `parts` array * reference changes **and** either the length differs or the last element is a * different object. This is optimized for the typical AI SDK streaming pattern * where parts are appended incrementally. Mid-array edits (e.g. replacing an * earlier part without appending) may not trigger recomputation. If you need to * force a recompute after such edits, pass a new array reference with a * different last element. * * @example * ```tsx * import { useJsonRenderMessage } from "@json-render/react"; * * function MessageBubble({ message }) { * const { spec, text, hasSpec } = useJsonRenderMessage(message.parts); * * return ( *
* {text && {text}} * {hasSpec && } *
* ); * } * ``` */ export function useJsonRenderMessage(parts: DataPart[]) { const prevPartsRef = useRef([]); const prevResultRef = useRef<{ spec: Spec | null; text: string }>({ spec: null, text: "", }); // Recompute only when parts actually change (by length + last element identity). // AI SDK typically appends to the parts array during streaming, so checking // length and the last element covers both "new array reference with same // content" (no recompute) and "new part appended" (recompute). const partsChanged = parts !== prevPartsRef.current && (parts.length !== prevPartsRef.current.length || parts[parts.length - 1] !== prevPartsRef.current[prevPartsRef.current.length - 1]); if (partsChanged || prevPartsRef.current.length === 0) { prevPartsRef.current = parts; prevResultRef.current = { spec: buildSpecFromParts(parts), text: getTextFromParts(parts), }; } const { spec, text } = prevResultRef.current; const hasSpec = spec !== null && Object.keys(spec.elements || {}).length > 0; return { spec, text, hasSpec }; } // ============================================================================= // useChatUI — Chat + GenUI hook // ============================================================================= /** * A single message in the chat, which may contain text, a rendered UI spec, or both. */ export interface ChatMessage { /** Unique message ID */ id: string; /** Who sent this message */ role: "user" | "assistant"; /** Text content (conversational prose) */ text: string; /** json-render Spec built from JSONL patches (null if no UI was generated) */ spec: Spec | null; } /** * Options for useChatUI */ export interface UseChatUIOptions { /** API endpoint that accepts `{ messages: Array<{ role, content }> }` and returns a text stream */ api: string; /** Callback when streaming completes for a message */ onComplete?: (message: ChatMessage) => void; /** Callback on error */ onError?: (error: Error) => void; } /** * Return type for useChatUI */ export interface UseChatUIReturn { /** All messages in the conversation */ messages: ChatMessage[]; /** Whether currently streaming an assistant response */ isStreaming: boolean; /** Error from the last request, if any */ error: Error | null; /** Send a user message */ send: (text: string) => Promise; /** Clear all messages and reset the conversation */ clear: () => void; } let chatMessageIdCounter = 0; function generateChatId(): string { if ( typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ) { return crypto.randomUUID(); } chatMessageIdCounter += 1; return `msg-${Date.now()}-${chatMessageIdCounter}`; } /** * Hook for chat + GenUI experiences. * * Manages a multi-turn conversation where each assistant message can contain * both conversational text and a json-render UI spec. The hook sends the full * message history to the API endpoint, reads the streamed response, and * separates text lines from JSONL patch lines using `createMixedStreamParser`. * * @example * ```tsx * const { messages, isStreaming, send, clear } = useChatUI({ * api: "/api/chat", * }); * * // Send a message * await send("Compare weather in NYC and Tokyo"); * * // Render messages * {messages.map((msg) => ( *
* {msg.text &&

{msg.text}

} * {msg.spec && } *
* ))} * ``` */ export function useChatUI({ api, onComplete, onError, }: UseChatUIOptions): UseChatUIReturn { const [messages, setMessages] = useState([]); const [isStreaming, setIsStreaming] = useState(false); const [error, setError] = useState(null); const abortControllerRef = useRef(null); // Keep a ref to the latest messages so `send` always reads the // current history, avoiding stale closure issues. const messagesRef = useRef(messages); messagesRef.current = messages; // Keep refs to callbacks so `send` doesn't recreate when consumers // pass inline arrow functions. const onCompleteRef = useRef(onComplete); onCompleteRef.current = onComplete; const onErrorRef = useRef(onError); onErrorRef.current = onError; const clear = useCallback(() => { setMessages([]); setError(null); }, []); const send = useCallback( async (text: string) => { if (!text.trim()) return; // Abort any existing request abortControllerRef.current?.abort(); abortControllerRef.current = new AbortController(); const userMessage: ChatMessage = { id: generateChatId(), role: "user", text: text.trim(), spec: null, }; const assistantId = generateChatId(); const assistantMessage: ChatMessage = { id: assistantId, role: "assistant", text: "", spec: null, }; // Append user message and empty assistant placeholder setMessages((prev) => [...prev, userMessage, assistantMessage]); setIsStreaming(true); setError(null); // Build messages array for the API (full conversation history + new message). // Read from ref to always get the latest messages (avoids stale closure). const historyForApi = [ ...messagesRef.current.map((m) => ({ role: m.role, content: m.text, })), { role: "user" as const, content: text.trim() }, ]; // Mutable state for accumulating the assistant response let accumulatedText = ""; let currentSpec: Spec = { root: "", elements: {} }; let hasSpec = false; try { const response = await fetch(api, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: historyForApi }), signal: abortControllerRef.current.signal, }); if (!response.ok) { let errorMessage = `HTTP error: ${response.status}`; try { const errorData = await response.json(); if (errorData.message) { errorMessage = errorData.message; } else if (errorData.error) { errorMessage = errorData.error; } } catch { // Ignore JSON parsing errors } throw new Error(errorMessage); } const reader = response.body?.getReader(); if (!reader) { throw new Error("No response body"); } const decoder = new TextDecoder(); // Use createMixedStreamParser to classify lines const parser = createMixedStreamParser({ onPatch(patch) { hasSpec = true; applySpecPatch(currentSpec, patch); setMessages((prev) => prev.map((m) => m.id === assistantId ? { ...m, spec: { root: currentSpec.root, elements: { ...currentSpec.elements }, ...(currentSpec.state ? { state: { ...currentSpec.state } } : {}), }, } : m, ), ); }, onText(line) { accumulatedText += (accumulatedText ? "\n" : "") + line; setMessages((prev) => prev.map((m) => m.id === assistantId ? { ...m, text: accumulatedText } : m, ), ); }, }); while (true) { const { done, value } = await reader.read(); if (done) break; parser.push(decoder.decode(value, { stream: true })); } parser.flush(); // Build final message for onComplete callback const finalMessage: ChatMessage = { id: assistantId, role: "assistant", text: accumulatedText, spec: hasSpec ? { root: currentSpec.root, elements: { ...currentSpec.elements }, ...(currentSpec.state ? { state: { ...currentSpec.state } } : {}), } : null, }; onCompleteRef.current?.(finalMessage); } catch (err) { if ((err as Error).name === "AbortError") { return; } const resolvedError = err instanceof Error ? err : new Error(String(err)); setError(resolvedError); // Remove empty assistant message on error setMessages((prev) => prev.filter((m) => m.id !== assistantId || m.text.length > 0), ); onErrorRef.current?.(resolvedError); } finally { setIsStreaming(false); } }, [api], ); // Cleanup on unmount useEffect(() => { return () => { abortControllerRef.current?.abort(); }; }, []); return { messages, isStreaming, error, send, clear, }; } ================================================ FILE: packages/react/src/index.ts ================================================ // Contexts export { StateProvider, useStateStore, useStateValue, useStateBinding, type StateContextValue, type StateProviderProps, } from "./contexts/state"; export { VisibilityProvider, useVisibility, useIsVisible, type VisibilityContextValue, type VisibilityProviderProps, } from "./contexts/visibility"; export { ActionProvider, useActions, useAction, ConfirmDialog, type ActionContextValue, type ActionProviderProps, type PendingConfirmation, type ConfirmDialogProps, } from "./contexts/actions"; export { ValidationProvider, useValidation, useOptionalValidation, useFieldValidation, type ValidationContextValue, type ValidationProviderProps, type FieldValidationState, } from "./contexts/validation"; export { RepeatScopeProvider, useRepeatScope, type RepeatScopeValue, } from "./contexts/repeat-scope"; // Schema (React's spec format) export { schema, type ReactSchema, type ReactSpec, // Backward compatibility elementTreeSchema, type ElementTreeSchema, type ElementTreeSpec, } from "./schema"; // Core types (re-exported for convenience) export type { Spec, StateStore } from "@json-render/core"; export { createStateStore } from "@json-render/core"; // Catalog-aware types for React export type { EventHandle, BaseComponentProps, SetState, StateModel, ComponentContext, ComponentFn, Components, ActionFn, Actions, } from "./catalog-types"; // Renderer export { // Registry defineRegistry, type DefineRegistryResult, // createRenderer (higher-level, includes providers) createRenderer, type CreateRendererProps, type ComponentMap, // Low-level Renderer, JSONUIProvider, type ComponentRenderProps, type ComponentRenderer, type ComponentRegistry, type RendererProps, type JSONUIProviderProps, } from "./renderer"; // Hooks export { useUIStream, useChatUI, useBoundProp, flatToTree, buildSpecFromParts, getTextFromParts, useJsonRenderMessage, type UseUIStreamOptions, type UseUIStreamReturn, type UseChatUIOptions, type UseChatUIReturn, type ChatMessage, type DataPart, type TokenUsage, } from "./hooks"; ================================================ FILE: packages/react/src/renderer.test.tsx ================================================ import { describe, it, expect } from "vitest"; import React from "react"; import { Renderer } from "./renderer"; describe("Renderer", () => { it("renders null for null spec", () => { const element = React.createElement(Renderer, { spec: null, registry: {}, }); expect(element).toBeDefined(); expect(element.props.spec).toBeNull(); }); it("renders null for spec without root", () => { const element = React.createElement(Renderer, { spec: { root: "", elements: {} }, registry: {}, }); expect(element).toBeDefined(); }); it("accepts loading prop", () => { const element = React.createElement(Renderer, { spec: null, registry: {}, loading: true, }); expect(element.props.loading).toBe(true); }); it("accepts fallback prop", () => { const Fallback = () => React.createElement("div", null, "Unknown component"); const element = React.createElement(Renderer, { spec: null, registry: {}, fallback: Fallback, }); expect(element.props.fallback).toBe(Fallback); }); }); ================================================ FILE: packages/react/src/renderer.tsx ================================================ "use client"; import React, { type ComponentType, type ErrorInfo, type ReactNode, useCallback, useEffect, useMemo, useRef, } from "react"; import type { UIElement, Spec, ActionBinding, Catalog, SchemaDefinition, StateStore, ComputedFunction, } from "@json-render/core"; import { resolveElementProps, resolveBindings, resolveActionParam, evaluateVisibility, getByPath, type PropResolutionContext, type VisibilityContext as CoreVisibilityContext, } from "@json-render/core"; import type { Components, Actions, ActionFn, SetState, StateModel, CatalogHasActions, EventHandle, } from "./catalog-types"; import { useIsVisible, useVisibility } from "./contexts/visibility"; import { useActions } from "./contexts/actions"; import { useStateStore } from "./contexts/state"; import { StateProvider } from "./contexts/state"; import { VisibilityProvider } from "./contexts/visibility"; import { ActionProvider } from "./contexts/actions"; import { ValidationProvider } from "./contexts/validation"; import { ConfirmDialog } from "./contexts/actions"; import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope"; /** * Props passed to component renderers */ export interface ComponentRenderProps

> { /** The element being rendered */ element: UIElement; /** Rendered children */ children?: ReactNode; /** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. Always provided by the renderer. */ emit: (event: string) => void; /** Get an event handle with metadata (shouldPreventDefault, bound). Use when you need to inspect event bindings. */ on: (event: string) => EventHandle; /** * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions. * Maps prop name → absolute state path for write-back. * Only present when at least one prop uses `{ $bindState: "..." }` or `{ $bindItem: "..." }`. */ bindings?: Record; /** Whether the parent is loading */ loading?: boolean; } /** * Component renderer type */ export type ComponentRenderer

> = ComponentType< ComponentRenderProps

>; /** * Registry of component renderers */ export type ComponentRegistry = Record>; /** * Props for the Renderer component */ export interface RendererProps { /** The UI spec to render */ spec: Spec | null; /** Component registry */ registry: ComponentRegistry; /** Whether the spec is currently loading/streaming */ loading?: boolean; /** Fallback component for unknown types */ fallback?: ComponentRenderer; } // --------------------------------------------------------------------------- // ElementErrorBoundary – catches rendering errors in individual elements so // a single bad component never crashes the whole page. // --------------------------------------------------------------------------- interface ElementErrorBoundaryProps { elementType: string; children: ReactNode; } interface ElementErrorBoundaryState { hasError: boolean; } class ElementErrorBoundary extends React.Component< ElementErrorBoundaryProps, ElementErrorBoundaryState > { constructor(props: ElementErrorBoundaryProps) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(): ElementErrorBoundaryState { return { hasError: true }; } componentDidCatch(error: Error, info: ErrorInfo) { console.error( `[json-render] Rendering error in <${this.props.elementType}>:`, error, info.componentStack, ); } render() { if (this.state.hasError) { // Render nothing – the element silently disappears rather than // crashing the entire application. return null; } return this.props.children; } } // --------------------------------------------------------------------------- // FunctionsContext – provides $computed functions to the element tree // --------------------------------------------------------------------------- const EMPTY_FUNCTIONS: Record = {}; const FunctionsContext = React.createContext>(EMPTY_FUNCTIONS); function useFunctions(): Record { return React.useContext(FunctionsContext); } interface ElementRendererProps { element: UIElement; spec: Spec; registry: ComponentRegistry; loading?: boolean; fallback?: ComponentRenderer; } /** * Element renderer component. * Memoized to prevent re-rendering all repeat children when state changes. */ const ElementRenderer = React.memo(function ElementRenderer({ element, spec, registry, loading, fallback, }: ElementRendererProps) { const repeatScope = useRepeatScope(); const { ctx } = useVisibility(); const { execute } = useActions(); const { getSnapshot, state: watchState } = useStateStore(); const functions = useFunctions(); // Build context with repeat scope and $computed functions const fullCtx: PropResolutionContext = useMemo(() => { const base: PropResolutionContext = repeatScope ? { ...ctx, repeatItem: repeatScope.item, repeatIndex: repeatScope.index, repeatBasePath: repeatScope.basePath, } : { ...ctx }; base.functions = functions; return base; }, [ctx, repeatScope, functions]); // Evaluate visibility (now supports $item/$index inside repeat scopes) const isVisible = element.visible === undefined ? true : evaluateVisibility(element.visible, fullCtx); // Create emit function that resolves events to action bindings. // Must be called before any early return to satisfy Rules of Hooks. const onBindings = element.on; const emit = useCallback( async (eventName: string) => { const binding = onBindings?.[eventName]; if (!binding) return; const actionBindings = Array.isArray(binding) ? binding : [binding]; for (const b of actionBindings) { if (!b.params) { await execute(b); continue; } // Build a fresh context with live store state so that $state // references in later actions see mutations from earlier ones. const liveCtx: PropResolutionContext = { ...fullCtx, stateModel: getSnapshot(), }; const resolved: Record = {}; for (const [key, val] of Object.entries(b.params)) { resolved[key] = resolveActionParam(val, liveCtx); } await execute({ ...b, params: resolved }); } }, [onBindings, execute, fullCtx, getSnapshot], ); // Create on() function that returns an EventHandle with metadata for a specific event. const on = useCallback( (eventName: string): EventHandle => { const binding = onBindings?.[eventName]; if (!binding) { return { emit: () => {}, shouldPreventDefault: false, bound: false }; } const actionBindings = Array.isArray(binding) ? binding : [binding]; const shouldPreventDefault = actionBindings.some((b) => b.preventDefault); return { emit: () => emit(eventName), shouldPreventDefault, bound: true, }; }, [onBindings, emit], ); // Watch effect: fire actions when watched state paths change. // Must be called before any early return to satisfy Rules of Hooks. // // Two refs serve distinct roles: // - `stableWatchRef` (useMemo): holds the last emitted values object so we // can return the same reference when watched values haven't changed, // preventing the downstream useEffect from firing on unrelated state updates. // - `prevWatchValues` (useEffect): tracks the previous watched-values snapshot // for change detection. Starts as `null` to skip the initial mount. const watchConfig = element.watch; const prevWatchValues = useRef | null>(null); const stableWatchRef = useRef | undefined>(undefined); const watchedValues = useMemo(() => { if (!watchConfig) return undefined; const values: Record = {}; for (const path of Object.keys(watchConfig)) { values[path] = getByPath(watchState, path); } const prev = stableWatchRef.current; if (prev) { const keys = Object.keys(values); if ( keys.length === Object.keys(prev).length && keys.every((k) => values[k] === prev[k]) ) { return prev; } } stableWatchRef.current = values; return values; }, [watchConfig, watchState]); useEffect(() => { if (!watchConfig || !watchedValues) return; const paths = Object.keys(watchConfig); if (paths.length === 0) return; const prev = prevWatchValues.current; prevWatchValues.current = watchedValues; // Skip the initial mount — only fire on changes if (prev === null) return; let cancelled = false; void (async () => { for (const path of paths) { if (cancelled) break; if (watchedValues[path] !== prev[path]) { const binding = watchConfig[path]; if (!binding) continue; const bindings = Array.isArray(binding) ? binding : [binding]; for (const b of bindings) { if (cancelled) break; if (!b.params) { await execute(b); if (cancelled) break; continue; } const liveCtx: PropResolutionContext = { ...fullCtx, stateModel: getSnapshot(), }; const resolved: Record = {}; for (const [key, val] of Object.entries(b.params)) { resolved[key] = resolveActionParam(val, liveCtx); } await execute({ ...b, params: resolved }); if (cancelled) break; } } } })().catch(console.error); return () => { cancelled = true; }; }, [watchConfig, watchedValues, execute, fullCtx, getSnapshot]); // Don't render if not visible if (!isVisible) { return null; } // Resolve $bindState/$bindItem expressions → bindings map (prop name → state path) const rawProps = element.props as Record; const elementBindings = resolveBindings(rawProps, fullCtx); // Resolve dynamic prop expressions ($state, $item, $index, $bindState, $bindItem, $cond/$then/$else) const resolvedProps = resolveElementProps(rawProps, fullCtx); const resolvedElement = resolvedProps !== element.props ? { ...element, props: resolvedProps } : element; // Get the component renderer const Component = registry[resolvedElement.type] ?? fallback; if (!Component) { console.warn(`No renderer for component type: ${resolvedElement.type}`); return null; } // ---- Render children (with repeat support) ---- const children = resolvedElement.repeat ? ( ) : ( resolvedElement.children?.map((childKey) => { const childElement = spec.elements[childKey]; if (!childElement) { if (!loading) { console.warn( `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`, ); } return null; } return ( ); }) ); return ( {children} ); }); // --------------------------------------------------------------------------- // RepeatChildren -- renders child elements once per item in a state array. // Used when an element has a `repeat` field. // --------------------------------------------------------------------------- function RepeatChildren({ element, spec, registry, loading, fallback, }: { element: UIElement; spec: Spec; registry: ComponentRegistry; loading?: boolean; fallback?: ComponentRenderer; }) { const { state } = useStateStore(); const repeat = element.repeat!; const statePath = repeat.statePath; const items = (getByPath(state, statePath) as unknown[] | undefined) ?? []; return ( <> {items.map((itemValue, index) => { // Use a stable key: prefer key field, fall back to index const key = repeat.key && typeof itemValue === "object" && itemValue !== null ? String( (itemValue as Record)[repeat.key] ?? index, ) : String(index); return ( {element.children?.map((childKey) => { const childElement = spec.elements[childKey]; if (!childElement) { if (!loading) { console.warn( `[json-render] Missing element "${childKey}" referenced as child of "${element.type}" (repeat). This element will not render.`, ); } return null; } return ( ); })} ); })} ); } /** * Main renderer component */ export function Renderer({ spec, registry, loading, fallback }: RendererProps) { if (!spec || !spec.root) { return null; } const rootElement = spec.elements[spec.root]; if (!rootElement) { return null; } return ( ); } /** * Props for JSONUIProvider */ export interface JSONUIProviderProps { /** Component registry */ registry: ComponentRegistry; /** * External store (controlled mode). When provided, `initialState` and * `onStateChange` are ignored. */ store?: StateStore; /** Initial state model (uncontrolled mode) */ initialState?: Record; /** Action handlers */ handlers?: Record< string, (params: Record) => Promise | unknown >; /** Navigation function */ navigate?: (path: string) => void; /** Custom validation functions */ validationFunctions?: Record< string, (value: unknown, args?: Record) => boolean >; /** Named functions for `$computed` expressions in props */ functions?: Record; /** Callback when state changes (uncontrolled mode) */ onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; children: ReactNode; } /** * Combined provider for all JSONUI contexts */ export function JSONUIProvider({ registry, store, initialState, handlers, navigate, validationFunctions, functions, onStateChange, children, }: JSONUIProviderProps) { return ( {children} ); } /** * Renders the confirmation dialog when needed */ function ConfirmationDialogManager() { const { pendingConfirmation, confirm, cancel } = useActions(); if (!pendingConfirmation?.action.confirm) { return null; } return ( ); } // ============================================================================ // defineRegistry // ============================================================================ /** * Result returned by defineRegistry */ export interface DefineRegistryResult { /** Component registry for `` */ registry: ComponentRegistry; /** * Create ActionProvider-compatible handlers. * Accepts getter functions so handlers always read the latest state/setState * (e.g. from React refs). */ handlers: ( getSetState: () => SetState | undefined, getState: () => StateModel, ) => Record) => Promise>; /** * Execute an action by name imperatively * (for use outside the React tree, e.g. initial state loading). */ executeAction: ( actionName: string, params: Record | undefined, setState: SetState, state?: StateModel, ) => Promise; } /** * Options for defineRegistry. * * When the catalog declares actions, the `actions` field is required. * When the catalog has no actions (or `actions: {}`), the field is optional. */ type DefineRegistryOptions = { components?: Components; } & (CatalogHasActions extends true ? { actions: Actions } : { actions?: Actions }); /** * Create a registry from a catalog with components and/or actions. * * When the catalog declares actions, the `actions` field is required. * * @example * ```tsx * // Components only (catalog has no actions) * const { registry } = defineRegistry(catalog, { * components: { * Card: ({ props, children }) => ( *

{props.title}{children}
* ), * }, * }); * * // Both (catalog declares actions) * const { registry, handlers, executeAction } = defineRegistry(catalog, { * components: { ... }, * actions: { ... }, * }); * ``` */ export function defineRegistry( _catalog: C, options: DefineRegistryOptions, ): DefineRegistryResult { // Build component registry const registry: ComponentRegistry = {}; if (options.components) { for (const [name, componentFn] of Object.entries(options.components)) { registry[name] = ({ element, children, emit, on, bindings, loading, }: ComponentRenderProps) => { return (componentFn as DefineRegistryComponentFn)({ props: element.props, children, emit, on, bindings, loading, }); }; } } // Build action helpers const actionMap = options.actions ? (Object.entries(options.actions) as Array< [string, DefineRegistryActionFn] >) : []; const handlers = ( getSetState: () => SetState | undefined, getState: () => StateModel, ): Record) => Promise> => { const result: Record< string, (params: Record) => Promise > = {}; for (const [name, actionFn] of actionMap) { result[name] = async (params) => { const setState = getSetState(); const state = getState(); if (setState) { await actionFn(params, setState, state); } }; } return result; }; const executeAction = async ( actionName: string, params: Record | undefined, setState: SetState, state: StateModel = {}, ): Promise => { const entry = actionMap.find(([name]) => name === actionName); if (entry) { await entry[1](params, setState, state); } else { console.warn(`Unknown action: ${actionName}`); } }; return { registry, handlers, executeAction }; } /** @internal */ type DefineRegistryComponentFn = (ctx: { props: unknown; children?: React.ReactNode; emit: (event: string) => void; on: (event: string) => EventHandle; bindings?: Record; loading?: boolean; }) => React.ReactNode; /** @internal */ type DefineRegistryActionFn = ( params: Record | undefined, setState: SetState, state: StateModel, ) => Promise; // ============================================================================ // NEW API // ============================================================================ /** * Props for renderers created with createRenderer */ export interface CreateRendererProps { /** The spec to render (AI-generated JSON) */ spec: Spec | null; /** * External store (controlled mode). When provided, `state` and * `onStateChange` are ignored. */ store?: StateStore; /** State context for dynamic values (uncontrolled mode) */ state?: Record; /** Action handler */ onAction?: (actionName: string, params?: Record) => void; /** Callback when state changes (uncontrolled mode) */ onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; /** Named functions for `$computed` expressions in props */ functions?: Record; /** Whether the spec is currently loading/streaming */ loading?: boolean; /** Fallback component for unknown types */ fallback?: ComponentRenderer; } /** * Component map type - maps component names to React components */ export type ComponentMap< TComponents extends Record, > = { [K in keyof TComponents]: ComponentType< ComponentRenderProps< TComponents[K]["props"] extends { _output: infer O } ? O : Record > >; }; /** * Create a renderer from a catalog * * @example * ```typescript * const DashboardRenderer = createRenderer(dashboardCatalog, { * Card: ({ element, children }) =>
{children}
, * Metric: ({ element }) => {element.props.value}, * }); * * // Usage * * ``` */ export function createRenderer< TDef extends SchemaDefinition, TCatalog extends { components: Record }, >( catalog: Catalog, components: ComponentMap, ): ComponentType { // Convert component map to registry const registry: ComponentRegistry = components as unknown as ComponentRegistry; // Return the renderer component return function CatalogRenderer({ spec, store, state, onAction, onStateChange, functions, loading, fallback, }: CreateRendererProps) { // Wrap onAction with a Proxy so any action name routes to the callback const actionHandlers = onAction ? new Proxy( {} as Record< string, (params: Record) => void | Promise >, { get: (_target, prop: string) => { return (params: Record) => onAction(prop, params); }, has: () => true, }, ) : undefined; return ( ); }; } ================================================ FILE: packages/react/src/schema.ts ================================================ import { defineSchema } from "@json-render/core"; /** * The schema for @json-render/react * * Defines: * - Spec: A flat tree of elements with keys, types, props, and children references * - Catalog: Components with props schemas, and optional actions */ export const schema = defineSchema( (s) => ({ // What the AI-generated SPEC looks like spec: s.object({ /** Root element key */ root: s.string(), /** Flat map of elements by key */ elements: s.record( s.object({ /** Component type from catalog */ type: s.ref("catalog.components"), /** Component props */ props: s.propsOf("catalog.components"), /** Child element keys (flat reference) */ children: s.array(s.string()), /** Visibility condition */ visible: s.any(), }), ), }), // What the CATALOG must provide catalog: s.object({ /** Component definitions */ components: s.map({ /** Zod schema for component props */ props: s.zod(), /** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */ slots: s.array(s.string()), /** Description for AI generation hints */ description: s.string(), /** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */ example: s.any(), }), /** Action definitions (optional) */ actions: s.map({ /** Zod schema for action params */ params: s.zod(), /** Description for AI generation hints */ description: s.string(), }), }), }), { builtInActions: [ { name: "setState", description: "Update a value in the state model at the given statePath. Params: { statePath: string, value: any }", }, { name: "pushState", description: '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.', }, { name: "removeState", description: "Remove an item from an array in state by index. Params: { statePath: string, index: number }", }, { name: "validateForm", description: "Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record }.", }, ], defaultRules: [ // Element integrity "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.", "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.", // Field placement 'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.', 'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.', // State and data "When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).", 'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.', // Design quality "Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.", "For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.", "Always include realistic, professional-looking sample data. For blogs include 3-4 posts with varied titles, authors, dates, categories. For products include names, prices, images. Never leave data empty.", ], }, ); /** * Type for the React schema */ export type ReactSchema = typeof schema; /** * Infer the spec type from a catalog */ export type ReactSpec = typeof schema extends { createCatalog: (catalog: TCatalog) => { _specType: infer S }; } ? S : never; // Backward compatibility aliases /** @deprecated Use `schema` instead */ export const elementTreeSchema = schema; /** @deprecated Use `ReactSchema` instead */ export type ElementTreeSchema = ReactSchema; /** @deprecated Use `ReactSpec` instead */ export type ElementTreeSpec = ReactSpec; ================================================ FILE: packages/react/tsconfig.json ================================================ { "extends": "@internal/typescript-config/react-library.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/react/tsup.config.ts ================================================ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts", "src/schema.ts"], format: ["cjs", "esm"], dts: { resolve: ["@internal/react-state"] }, sourcemap: true, clean: true, noExternal: ["@internal/react-state"], external: ["react", "react-dom", "@json-render/core"], }); ================================================ FILE: packages/react-email/CHANGELOG.md ================================================ # @json-render/react-email ## 0.14.1 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 ## 0.14.0 ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 ## 0.13.0 ### Patch Changes - Updated dependencies [5b32de8] - @json-render/core@0.13.0 ## 0.12.1 ### Patch Changes - Updated dependencies [54a1ecf] - @json-render/core@0.12.1 ## 0.12.0 ### Minor Changes - 63c339b: Add Svelte renderer, React Email renderer, and MCP Apps integration. ### New: - **`@json-render/svelte`** — Svelte 5 renderer with runes-based reactivity. Full support for data binding, visibility, actions, validation, watchers, streaming, and repeat scopes. Includes `defineRegistry`, `Renderer`, `schema`, composables, and context providers. - **`@json-render/react-email`** — React Email renderer for generating HTML and plain-text emails from JSON specs. 17 standard components (Html, Head, Body, Container, Section, Row, Column, Heading, Text, Link, Button, Image, Hr, Preview, Markdown). Server-side `renderToHtml` / `renderToPlainText` APIs. Custom catalog and registry support. - **`@json-render/mcp`** — MCP Apps integration that serves json-render UIs as interactive apps inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients. `createMcpApp` server factory, `useJsonRenderApp` React hook for iframes, and `buildAppHtml` utility. ### Fixed: - **`@json-render/svelte`** — Corrected JSDoc comment and added missing `zod` peer dependency. ### Patch Changes - Updated dependencies [63c339b] - @json-render/core@0.12.0 ================================================ FILE: packages/react-email/README.md ================================================ # @json-render/react-email React Email renderer for `@json-render/core`. Generate HTML and plain-text emails from JSON specs using `@react-email/components` and `@react-email/render`. ## Install ```bash npm install @json-render/core @json-render/react-email @react-email/components @react-email/render ``` ## Quick Start ### Render a spec to HTML ```typescript import { renderToHtml } from "@json-render/react-email"; import type { Spec } from "@json-render/core"; const spec: Spec = { root: "html-1", elements: { "html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] }, "head-1": { type: "Head", props: {}, children: [] }, "body-1": { type: "Body", props: { style: { backgroundColor: "#f6f9fc" } }, children: ["container-1"], }, "container-1": { type: "Container", props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } }, children: ["heading-1", "text-1"], }, "heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] }, "text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] }, }, }; const html = await renderToHtml(spec); ``` ### With a custom catalog ```typescript import { defineCatalog } from "@json-render/core"; import { schema, defineRegistry, renderToHtml } from "@json-render/react-email"; import { standardComponentDefinitions } from "@json-render/react-email/catalog"; import { Container, Heading, Text } from "@react-email/components"; import { z } from "zod"; const catalog = defineCatalog(schema, { components: { ...standardComponentDefinitions, Alert: { props: z.object({ message: z.string(), variant: z.enum(["info", "success", "warning"]).nullable(), }), slots: [], description: "A highlighted message block", }, }, actions: {}, }); const { registry } = defineRegistry(catalog, { components: { Alert: ({ props }) => ( {props.message} ), }, }); const html = await renderToHtml(spec, { registry }); ``` ## Standard Components ### Document structure | Component | Description | |-----------|-------------| | `Html` | Top-level email wrapper. Must be the root element. | | `Head` | Email head section. | | `Body` | Email body wrapper. | ### Layout | Component | Description | |-----------|-------------| | `Container` | Constrains content width. | | `Section` | Groups related content. | | `Row` | Horizontal layout row. | | `Column` | Column within a Row. | ### Content | Component | Description | |-----------|-------------| | `Heading` | Heading text (h1–h6). | | `Text` | Body text paragraph. | | `Link` | Hyperlink. | | `Button` | Call-to-action button. | | `Image` | Image from URL. | | `Hr` | Horizontal rule. | ### Utility | Component | Description | |-----------|-------------| | `Preview` | Inbox preview text. | | `Markdown` | Markdown content as email-safe HTML. | ## Server-Side APIs ```typescript import { renderToHtml, renderToPlainText } from "@json-render/react-email"; const html = await renderToHtml(spec); const plainText = await renderToPlainText(spec); ``` Both accept an optional second argument with: - `registry` — Custom component registry (merged with standard components) - `includeStandard` — Include built-in standard components (default: `true`) - `state` — Initial state for `$state` / `$cond` dynamic prop resolution ## Server-Safe Import Import schema and catalog definitions without pulling in React or `@react-email/components`: ```typescript import { schema, standardComponentDefinitions } from "@json-render/react-email/server"; ``` ## Documentation Full API reference: [json-render.dev/docs/api/react-email](https://json-render.dev/docs/api/react-email). Example app: [examples/react-email](https://github.com/vercel-labs/json-render/tree/main/examples/react-email). ## License Apache-2.0 ================================================ FILE: packages/react-email/package.json ================================================ { "name": "@json-render/react-email", "version": "0.14.1", "license": "Apache-2.0", "description": "React Email renderer for @json-render/core. JSON becomes HTML emails.", "keywords": [ "json", "email", "react-email", "ai", "generative-ui", "llm", "renderer", "html" ], "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", "directory": "packages/react-email" }, "homepage": "https://json-render.dev", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "publishConfig": { "access": "public" }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" }, "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.mjs", "require": "./dist/server.js" }, "./catalog": { "types": "./dist/catalog.d.ts", "import": "./dist/catalog.mjs", "require": "./dist/catalog.js" }, "./render": { "types": "./dist/render.d.ts", "import": "./dist/render.mjs", "require": "./dist/render.js" } }, "files": [ "dist" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "check-types": "tsc --noEmit", "typecheck": "tsc --noEmit" }, "dependencies": { "@json-render/core": "workspace:*", "@react-email/components": "^1.0.8", "@react-email/render": "^2.0.4" }, "devDependencies": { "@internal/typescript-config": "workspace:*", "@types/react": "19.2.3", "tsup": "^8.0.2", "typescript": "^5.4.5", "zod": "^4.0.0" }, "peerDependencies": { "react": "^19.0.0", "zod": "^4.0.0" } } ================================================ FILE: packages/react-email/src/__fixtures__/examples.ts ================================================ import type { Spec } from "@json-render/core"; export interface Example { name: string; label: string; description: string; spec: Spec; } // Images sourced from the react-email demo; URLs are only used for spec // structure validation in tests and are not fetched at runtime. const staticUrl = "https://react-email-demo-bdj5iju9r-resend.vercel.app/static"; export const examples: Example[] = [ { name: "vercel-invite", label: "Vercel Invite", description: "Team invitation email with avatars and CTA", spec: { root: "html", elements: { html: { type: "Html", props: { lang: "en", dir: null }, children: ["head", "preview", "body"], }, head: { type: "Head", props: {}, children: [], }, preview: { type: "Preview", props: { text: "Join Alan on Vercel" }, children: [], }, body: { type: "Body", props: { style: { backgroundColor: "#ffffff", fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif', margin: "0 auto", padding: "0 8px", }, }, children: ["container"], }, container: { type: "Container", props: { style: { maxWidth: "465px", margin: "40px auto", border: "1px solid #eaeaea", borderRadius: "4px", padding: "20px", }, }, children: [ "logo-section", "heading", "greeting-text", "invite-text", "avatar-section", "button-section", "url-text", "invite-link", "hr", "footer-text", ], }, "logo-section": { type: "Section", props: { style: { marginTop: "32px" } }, children: ["logo"], }, logo: { type: "Image", props: { src: `${staticUrl}/vercel-logo.png`, width: 40, height: 37, alt: "Vercel", style: { margin: "0 auto", display: "block" }, }, children: [], }, heading: { type: "Heading", props: { text: "Join Enigma on Vercel", as: "h1", style: { color: "#000000", fontSize: "24px", fontWeight: "normal", textAlign: "center", margin: "30px 0", padding: "0", }, }, children: [], }, "greeting-text": { type: "Text", props: { text: "Hello alanturing,", style: { color: "#000000", fontSize: "14px", lineHeight: "24px", }, }, children: [], }, "invite-text": { type: "Text", props: { text: "Alan (alan.turing@example.com) has invited you to the Enigma team on Vercel.", style: { color: "#000000", fontSize: "14px", lineHeight: "24px", }, }, children: [], }, "avatar-section": { type: "Section", props: { style: {} }, children: ["avatar-row"], }, "avatar-row": { type: "Row", props: { style: {} }, children: ["user-avatar-col", "arrow-col", "team-avatar-col"], }, "user-avatar-col": { type: "Column", props: { style: {} }, children: ["user-avatar"], }, "user-avatar": { type: "Image", props: { src: `${staticUrl}/vercel-user.png`, width: 64, height: 64, alt: "alanturing", style: { borderRadius: "50%", marginLeft: "auto" }, }, children: [], }, "arrow-col": { type: "Column", props: { style: {} }, children: ["arrow-img"], }, "arrow-img": { type: "Image", props: { src: `${staticUrl}/vercel-arrow.png`, width: 12, height: 9, alt: "invited to", style: { margin: "0 auto" }, }, children: [], }, "team-avatar-col": { type: "Column", props: { style: {} }, children: ["team-avatar"], }, "team-avatar": { type: "Image", props: { src: `${staticUrl}/vercel-team.png`, width: 64, height: 64, alt: "Enigma", style: { borderRadius: "50%", marginRight: "auto" }, }, children: [], }, "button-section": { type: "Section", props: { style: { marginTop: "32px", marginBottom: "32px", textAlign: "center", }, }, children: ["join-button"], }, "join-button": { type: "Button", props: { text: "Join the team", href: "https://vercel.com/teams/invite/foo", style: { backgroundColor: "#000000", borderRadius: "4px", color: "#ffffff", fontSize: "12px", fontWeight: "600", textDecoration: "none", textAlign: "center", padding: "12px 20px", }, }, children: [], }, "url-text": { type: "Text", props: { text: "or copy and paste this URL into your browser:", style: { color: "#000000", fontSize: "14px", lineHeight: "24px", }, }, children: [], }, "invite-link": { type: "Link", props: { text: "https://vercel.com/teams/invite/foo", href: "https://vercel.com/teams/invite/foo", style: { color: "#2563eb", textDecoration: "none", fontSize: "14px", }, }, children: [], }, hr: { type: "Hr", props: { style: { borderColor: "#eaeaea", margin: "26px 0" } }, children: [], }, "footer-text": { type: "Text", props: { text: "This invitation was intended for alanturing. This invite was sent from 204.13.186.218 located in São Paulo, Brazil. If you were not expecting this invitation, you can ignore this email. If you are concerned about your account's safety, please reply to this email to get in touch with us.", style: { color: "#666666", fontSize: "12px", lineHeight: "24px", }, }, children: [], }, }, }, }, { name: "stripe-welcome", label: "Stripe Welcome", description: "Onboarding email with dashboard CTA", spec: { root: "html", elements: { html: { type: "Html", props: { lang: "en", dir: null }, children: ["head", "preview", "body"], }, head: { type: "Head", props: {}, children: [], }, preview: { type: "Preview", props: { text: "You're now ready to make live transactions with Stripe!", }, children: [], }, body: { type: "Body", props: { style: { backgroundColor: "#f6f9fc", fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif', }, }, children: ["container"], }, container: { type: "Container", props: { style: { backgroundColor: "#ffffff", margin: "0 auto", padding: "20px 0 48px", marginBottom: "64px", }, }, children: ["content-section"], }, "content-section": { type: "Section", props: { style: { padding: "0 48px" } }, children: [ "logo", "hr1", "text-intro", "text-dashboard", "cta-button", "hr2", "text-docs", "docs-link", "text-api-keys", "text-checklist", "text-support", "text-signoff", "hr3", "footer-text", ], }, logo: { type: "Image", props: { src: `${staticUrl}/stripe-logo.png`, width: 49, height: 21, alt: "Stripe", style: null, }, children: [], }, hr1: { type: "Hr", props: { style: { borderColor: "#e6ebf1", margin: "20px 0" } }, children: [], }, "text-intro": { type: "Text", props: { text: "Thanks for submitting your account information. You're now ready to make live transactions with Stripe!", style: { color: "#525f7f", fontSize: "16px", lineHeight: "24px", textAlign: "left", }, }, children: [], }, "text-dashboard": { type: "Text", props: { text: "You can view your payments and a variety of other information about your account right from your dashboard.", style: { color: "#525f7f", fontSize: "16px", lineHeight: "24px", textAlign: "left", }, }, children: [], }, "cta-button": { type: "Button", props: { text: "View your Stripe Dashboard", href: "https://dashboard.stripe.com/login", style: { backgroundColor: "#656ee8", borderRadius: "3px", color: "#ffffff", fontSize: "16px", fontWeight: "bold", textDecoration: "none", textAlign: "center", display: "block", padding: "10px", }, }, children: [], }, hr2: { type: "Hr", props: { style: { borderColor: "#e6ebf1", margin: "20px 0" } }, children: [], }, "text-docs": { type: "Text", props: { text: "If you haven't finished your integration, you might find our docs handy.", style: { color: "#525f7f", fontSize: "16px", lineHeight: "24px", textAlign: "left", }, }, children: [], }, "docs-link": { type: "Link", props: { text: "Stripe Documentation — Getting Started", href: "https://docs.stripe.com/dashboard/basics", style: { color: "#556cd6", fontSize: "16px", }, }, children: [], }, "text-api-keys": { type: "Text", props: { text: "Once you're ready to start accepting payments, you'll just need to use your live API keys instead of your test API keys. Your account can simultaneously be used for both test and live requests, so you can continue testing while accepting live payments. Check out our tutorial about account basics.", style: { color: "#525f7f", fontSize: "16px", lineHeight: "24px", textAlign: "left", }, }, children: [], }, "text-checklist": { type: "Text", props: { text: "Finally, we've put together a quick checklist to ensure your website conforms to card network standards.", style: { color: "#525f7f", fontSize: "16px", lineHeight: "24px", textAlign: "left", }, }, children: [], }, "text-support": { type: "Text", props: { text: "We'll be here to help you with any step along the way. You can find answers to most questions and get in touch with us on our support site.", style: { color: "#525f7f", fontSize: "16px", lineHeight: "24px", textAlign: "left", }, }, children: [], }, "text-signoff": { type: "Text", props: { text: "— The Stripe team", style: { color: "#525f7f", fontSize: "16px", lineHeight: "24px", textAlign: "left", }, }, children: [], }, hr3: { type: "Hr", props: { style: { borderColor: "#e6ebf1", margin: "20px 0" } }, children: [], }, "footer-text": { type: "Text", props: { text: "Stripe, 354 Oyster Point Blvd, South San Francisco, CA 94080", style: { color: "#8898aa", fontSize: "12px", lineHeight: "16px", }, }, children: [], }, }, }, }, { name: "nike-receipt", label: "Nike Receipt", description: "Order shipment notification with product details", spec: { root: "html", elements: { html: { type: "Html", props: { lang: "en", dir: null }, children: ["head", "preview", "body"], }, head: { type: "Head", props: {}, children: [], }, preview: { type: "Preview", props: { text: "Get your order summary, estimated delivery date and more", }, children: [], }, body: { type: "Body", props: { style: { backgroundColor: "#ffffff", fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif', }, }, children: ["container"], }, container: { type: "Container", props: { style: { margin: "10px auto", width: "600px", maxWidth: "100%", border: "1px solid #E5E5E5", }, }, children: [ "tracking-section", "hr1", "hero-section", "hr2", "shipping-section", "hr3", "product-section", "hr4", "order-info-section", "hr5", "footer-section", ], }, // ── Tracking Section ── "tracking-section": { type: "Section", props: { style: { padding: "22px 40px", backgroundColor: "#F7F7F7", }, }, children: ["tracking-row"], }, "tracking-row": { type: "Row", props: { style: {} }, children: ["tracking-info-col", "tracking-btn-col"], }, "tracking-info-col": { type: "Column", props: { style: {} }, children: ["tracking-label", "tracking-number"], }, "tracking-label": { type: "Text", props: { text: "Tracking Number", style: { margin: "0", fontSize: "14px", lineHeight: "2", fontWeight: "bold", }, }, children: [], }, "tracking-number": { type: "Text", props: { text: "1ZV218970300071628", style: { margin: "12px 0 0", fontSize: "14px", lineHeight: "1.4", fontWeight: "500", color: "#6F6F6F", }, }, children: [], }, "tracking-btn-col": { type: "Column", props: { style: { textAlign: "right" } }, children: ["tracking-link"], }, "tracking-link": { type: "Link", props: { text: "Track Package", href: "https://www.nike.com/orders", style: { border: "1px solid #929292", fontSize: "16px", textDecoration: "none", padding: "10px 0", width: "220px", display: "block", textAlign: "center", fontWeight: "500", color: "#000000", }, }, children: [], }, hr1: { type: "Hr", props: { style: { borderColor: "#E5E5E5", margin: "0" } }, children: [], }, // ── Hero Section ── "hero-section": { type: "Section", props: { style: { padding: "40px 74px", textAlign: "center", }, }, children: [ "nike-logo", "hero-heading", "hero-text", "hero-text-payment", ], }, "nike-logo": { type: "Image", props: { src: `${staticUrl}/nike-logo.png`, width: 66, height: 22, alt: "Nike", style: { margin: "0 auto", display: "block" }, }, children: [], }, "hero-heading": { type: "Heading", props: { text: "It's On Its Way.", as: "h1", style: { fontSize: "32px", lineHeight: "1.3", fontWeight: "bold", textAlign: "center", letterSpacing: "-1px", }, }, children: [], }, "hero-text": { type: "Text", props: { text: "Your order is on its way. Use the link above to track its progress.", style: { margin: "0", fontSize: "14px", lineHeight: "2", color: "#747474", fontWeight: "500", }, }, children: [], }, "hero-text-payment": { type: "Text", props: { text: "We've also charged your payment method for the cost of your order and will be removing any authorization holds. For payment details, please visit your Orders page on Nike.com or in the Nike app.", style: { margin: "24px 0 0", fontSize: "14px", lineHeight: "2", color: "#747474", fontWeight: "500", }, }, children: [], }, hr2: { type: "Hr", props: { style: { borderColor: "#E5E5E5", margin: "0" } }, children: [], }, // ── Shipping Section ── "shipping-section": { type: "Section", props: { style: { padding: "22px 40px" } }, children: ["shipping-label", "shipping-address"], }, "shipping-label": { type: "Text", props: { text: "Shipping to: Alan Turing", style: { margin: "0", fontSize: "15px", lineHeight: "2", fontWeight: "bold", }, }, children: [], }, "shipping-address": { type: "Text", props: { text: "2125 Chestnut St, San Francisco, CA 94123", style: { margin: "0", fontSize: "14px", lineHeight: "2", color: "#747474", fontWeight: "500", }, }, children: [], }, hr3: { type: "Hr", props: { style: { borderColor: "#E5E5E5", margin: "0" } }, children: [], }, // ── Product Section ── "product-section": { type: "Section", props: { style: { padding: "40px" } }, children: ["product-row"], }, "product-row": { type: "Row", props: { style: {} }, children: ["product-img-col", "product-details-col"], }, "product-img-col": { type: "Column", props: { style: {} }, children: ["product-img"], }, "product-img": { type: "Image", props: { src: `${staticUrl}/nike-product.png`, alt: "Brazil 2022/23 Stadium Away Women's Nike Dri-FIT Soccer Jersey", width: 260, height: null, style: { float: "left" }, }, children: [], }, "product-details-col": { type: "Column", props: { style: { verticalAlign: "top", paddingLeft: "12px" }, }, children: ["product-name", "product-size"], }, "product-name": { type: "Text", props: { text: "Brazil 2022/23 Stadium Away Women's Nike Dri-FIT Soccer Jersey", style: { margin: "0", fontSize: "14px", lineHeight: "2", fontWeight: "500", }, }, children: [], }, "product-size": { type: "Text", props: { text: "Size L (12–14)", style: { margin: "0", fontSize: "14px", lineHeight: "2", color: "#747474", fontWeight: "500", }, }, children: [], }, hr4: { type: "Hr", props: { style: { borderColor: "#E5E5E5", margin: "0" } }, children: [], }, // ── Order Info Section ── "order-info-section": { type: "Section", props: { style: { padding: "22px 40px" } }, children: ["order-meta-row", "order-status-row"], }, "order-meta-row": { type: "Row", props: { style: { marginBottom: "40px" } }, children: ["order-number-col", "order-date-col"], }, "order-number-col": { type: "Column", props: { style: { width: "170px" } }, children: ["order-number-label", "order-number-value"], }, "order-number-label": { type: "Text", props: { text: "Order Number", style: { margin: "0", fontSize: "14px", lineHeight: "2", fontWeight: "bold", }, }, children: [], }, "order-number-value": { type: "Text", props: { text: "C0106373851", style: { margin: "12px 0 0", fontSize: "14px", lineHeight: "1.4", fontWeight: "500", color: "#6F6F6F", }, }, children: [], }, "order-date-col": { type: "Column", props: { style: {} }, children: ["order-date-label", "order-date-value"], }, "order-date-label": { type: "Text", props: { text: "Order Date", style: { margin: "0", fontSize: "14px", lineHeight: "2", fontWeight: "bold", }, }, children: [], }, "order-date-value": { type: "Text", props: { text: "Sep 22, 2022", style: { margin: "12px 0 0", fontSize: "14px", lineHeight: "1.4", fontWeight: "500", color: "#6F6F6F", }, }, children: [], }, "order-status-row": { type: "Row", props: { style: {} }, children: ["order-status-col"], }, "order-status-col": { type: "Column", props: { style: { textAlign: "center" } }, children: ["order-status-link"], }, "order-status-link": { type: "Link", props: { text: "Order Status", href: "https://www.nike.com/orders", style: { border: "1px solid #929292", fontSize: "16px", textDecoration: "none", padding: "10px 0", width: "220px", display: "block", textAlign: "center", fontWeight: "500", color: "#000000", margin: "0 auto", }, }, children: [], }, hr5: { type: "Hr", props: { style: { borderColor: "#E5E5E5", margin: "0" } }, children: [], }, // ── Footer Section ── "footer-section": { type: "Section", props: { style: { padding: "22px 0", textAlign: "center" } }, children: [ "footer-brand", "footer-nav-row", "footer-contact", "footer-copyright", "footer-address", ], }, "footer-brand": { type: "Text", props: { text: "Nike.com", style: { fontSize: "32px", lineHeight: "1.3", fontWeight: "bold", textAlign: "center", letterSpacing: "-1px", }, }, children: [], }, "footer-nav-row": { type: "Row", props: { style: { width: "370px", margin: "0 auto" } }, children: [ "footer-nav-men", "footer-nav-women", "footer-nav-kids", "footer-nav-customize", ], }, "footer-nav-men": { type: "Column", props: { style: { textAlign: "center" } }, children: ["link-men"], }, "link-men": { type: "Link", props: { text: "Men", href: "https://www.nike.com/", style: { fontWeight: "500", color: "#000000" }, }, children: [], }, "footer-nav-women": { type: "Column", props: { style: { textAlign: "center" } }, children: ["link-women"], }, "link-women": { type: "Link", props: { text: "Women", href: "https://www.nike.com/", style: { fontWeight: "500", color: "#000000" }, }, children: [], }, "footer-nav-kids": { type: "Column", props: { style: { textAlign: "center" } }, children: ["link-kids"], }, "link-kids": { type: "Link", props: { text: "Kids", href: "https://www.nike.com/", style: { fontWeight: "500", color: "#000000" }, }, children: [], }, "footer-nav-customize": { type: "Column", props: { style: { textAlign: "center" } }, children: ["link-customize"], }, "link-customize": { type: "Link", props: { text: "Customize", href: "https://www.nike.com/", style: { fontWeight: "500", color: "#000000" }, }, children: [], }, "footer-contact": { type: "Text", props: { text: "Please contact us if you have any questions. (If you reply to this email, we won't be able to see it.)", style: { margin: "0", color: "#AFAFAF", fontSize: "13px", textAlign: "center", padding: "30px 0", }, }, children: [], }, "footer-copyright": { type: "Text", props: { text: "© 2022 Nike, Inc. All Rights Reserved.", style: { margin: "0", color: "#AFAFAF", fontSize: "13px", textAlign: "center", }, }, children: [], }, "footer-address": { type: "Text", props: { text: "NIKE, INC. One Bowerman Drive, Beaverton, Oregon 97005, USA.", style: { margin: "0", color: "#AFAFAF", fontSize: "13px", textAlign: "center", }, }, children: [], }, }, }, }, ]; ================================================ FILE: packages/react-email/src/catalog-types.ts ================================================ import type { ReactNode } from "react"; import type { Catalog, InferCatalogComponents, InferComponentProps, StateModel, } from "@json-render/core"; export type { StateModel }; // ============================================================================= // State Types // ============================================================================= export type SetState = ( updater: (prev: Record) => Record, ) => void; // ============================================================================= // Component Types // ============================================================================= export interface ComponentContext< C extends Catalog, K extends keyof InferCatalogComponents, > { props: InferComponentProps; children?: ReactNode; emit: (event: string) => void; bindings?: Record; loading?: boolean; } export type ComponentFn< C extends Catalog, K extends keyof InferCatalogComponents, > = (ctx: ComponentContext) => ReactNode; export type Components = { [K in keyof InferCatalogComponents]: ComponentFn; }; ================================================ FILE: packages/react-email/src/catalog.ts ================================================ import { z } from "zod"; const styleSchema = z.record(z.string(), z.any()).nullable(); /** * Standard component definitions for React Email catalogs. * * These define the available email components with their Zod prop schemas. * All components render using @react-email/components primitives. */ export const standardComponentDefinitions = { // ========================================================================== // Document Structure // ========================================================================== Html: { props: z.object({ lang: z.string().nullable(), dir: z.enum(["ltr", "rtl"]).nullable(), }), slots: ["default"], description: "Top-level HTML email wrapper. Must be the root element. Children should include Head and Body.", example: { lang: "en", dir: "ltr" }, }, Head: { props: z.object({}), slots: ["default"], description: "Email head section. Place inside Html. Can contain metadata but typically left empty.", example: {}, }, Body: { props: z.object({ style: styleSchema, }), slots: ["default"], description: "Email body wrapper. Place inside Html after Head. Contains all visible email content.", example: { style: { backgroundColor: "#f6f9fc" } }, }, Container: { props: z.object({ style: styleSchema, }), slots: ["default"], description: "Constrains content width for email clients. Place inside Body. Typically max-width 600px.", example: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px 0 48px", }, }, }, Section: { props: z.object({ style: styleSchema, }), slots: ["default"], description: "Groups related content. Renders as a table-based section for email compatibility.", example: { style: { padding: "24px", backgroundColor: "#ffffff" } }, }, Row: { props: z.object({ style: styleSchema, }), slots: ["default"], description: "Horizontal layout row. Use inside Section for multi-column layouts.", example: { style: {} }, }, Column: { props: z.object({ style: styleSchema, }), slots: ["default"], description: "Column within a Row. Set width via style for proportional layouts.", example: { style: { width: "50%" } }, }, // ========================================================================== // Content Components // ========================================================================== Heading: { props: z.object({ text: z.string(), as: z.enum(["h1", "h2", "h3", "h4", "h5", "h6"]).nullable(), style: styleSchema, }), slots: [], description: "Heading text at various levels. h1 is largest, h6 is smallest.", example: { text: "Welcome!", as: "h1" }, }, Text: { props: z.object({ text: z.string(), style: styleSchema, }), slots: [], description: "Body text paragraph. Use style for font size, color, weight, and alignment.", example: { text: "Thank you for signing up." }, }, Link: { props: z.object({ text: z.string(), href: z.string(), style: styleSchema, }), slots: [], description: "Hyperlink with visible text and a URL.", example: { text: "Visit our website", href: "https://example.com", style: { color: "#2563eb" }, }, }, Button: { props: z.object({ text: z.string(), href: z.string(), style: styleSchema, }), slots: [], description: "Call-to-action button rendered as a link styled as a button. Provide text and href.", example: { text: "Get Started", href: "https://example.com", style: { backgroundColor: "#5F51E8", borderRadius: "3px", color: "#fff", padding: "12px 20px", }, }, }, Image: { props: z.object({ src: z.string(), alt: z.string().nullable(), width: z.number().nullable(), height: z.number().nullable(), style: styleSchema, }), slots: [], description: "Image from a URL. src must be a fully qualified URL. Specify width and height for consistent rendering.", example: { src: "https://picsum.photos/400/200?random=1", alt: "Hero image", width: 400, height: 200, }, }, Hr: { props: z.object({ style: styleSchema, }), slots: [], description: "Horizontal rule separator between content sections.", example: { style: { borderColor: "#e6ebf1", margin: "20px 0" }, }, }, // ========================================================================== // Utility Components // ========================================================================== Preview: { props: z.object({ text: z.string(), }), slots: [], description: "Preview text shown in email client inboxes before the email is opened. Place inside Html.", example: { text: "You have a new message from Acme Corp" }, }, Markdown: { props: z.object({ content: z.string(), markdownContainerStyles: styleSchema, markdownCustomStyles: z.record(z.string(), z.any()).nullable(), }), slots: [], description: "Renders markdown content as email-safe HTML. Supports headings, paragraphs, lists, links, bold, italic, and code.", example: { content: "# Hello\n\nThis is **bold** and *italic* text.", }, }, }; export type StandardComponentDefinitions = typeof standardComponentDefinitions; export type StandardComponentProps< K extends keyof StandardComponentDefinitions, > = StandardComponentDefinitions[K]["props"] extends { _output: infer O } ? O : z.output; ================================================ FILE: packages/react-email/src/components/index.ts ================================================ export { standardComponents } from "./standard"; ================================================ FILE: packages/react-email/src/components/standard.tsx ================================================ import React from "react"; import { Html as EmailHtml, Head as EmailHead, Body as EmailBody, Container as EmailContainer, Section as EmailSection, Row as EmailRow, Column as EmailColumn, Heading as EmailHeading, Text as EmailText, Link as EmailLink, Button as EmailButton, Img as EmailImg, Hr as EmailHr, Preview as EmailPreview, Markdown as EmailMarkdown, } from "@react-email/components"; import type { ComponentRenderProps } from "../renderer"; import type { ComponentRegistry } from "../renderer"; import type { StandardComponentProps } from "../catalog"; // ============================================================================= // Document Structure // ============================================================================= function HtmlComponent({ element, children, }: ComponentRenderProps>) { const p = element.props; return ( {children} ); } function HeadComponent({ children, }: ComponentRenderProps>) { return {children}; } function BodyComponent({ element, children, }: ComponentRenderProps>) { const p = element.props; return {children}; } function ContainerComponent({ element, children, }: ComponentRenderProps>) { const p = element.props; return ( {children} ); } function SectionComponent({ element, children, }: ComponentRenderProps>) { const p = element.props; return {children}; } function RowComponent({ element, children, }: ComponentRenderProps>) { const p = element.props; return {children}; } function ColumnComponent({ element, children, }: ComponentRenderProps>) { const p = element.props; return {children}; } // ============================================================================= // Content Components // ============================================================================= function HeadingComponent({ element, }: ComponentRenderProps>) { const p = element.props; return ( {p.text} ); } function TextComponent({ element, }: ComponentRenderProps>) { const p = element.props; return {p.text}; } function LinkComponent({ element, }: ComponentRenderProps>) { const p = element.props; return ( {p.text} ); } function ButtonComponent({ element, }: ComponentRenderProps>) { const p = element.props; return ( {p.text} ); } function ImageComponent({ element, }: ComponentRenderProps>) { const p = element.props; return ( ); } function HrComponent({ element, }: ComponentRenderProps>) { const p = element.props; return ; } // ============================================================================= // Utility Components // ============================================================================= function PreviewComponent({ element, }: ComponentRenderProps>) { const p = element.props; return {p.text}; } function MarkdownComponent({ element, }: ComponentRenderProps>) { const p = element.props; return ( {p.content} ); } // ============================================================================= // Registry // ============================================================================= export const standardComponents: ComponentRegistry = { Html: HtmlComponent, Head: HeadComponent, Body: BodyComponent, Container: ContainerComponent, Section: SectionComponent, Row: RowComponent, Column: ColumnComponent, Heading: HeadingComponent, Text: TextComponent, Link: LinkComponent, Button: ButtonComponent, Image: ImageComponent, Hr: HrComponent, Preview: PreviewComponent, Markdown: MarkdownComponent, }; ================================================ FILE: packages/react-email/src/contexts/actions.tsx ================================================ import React, { createContext, useContext, useState, useCallback, useMemo, type ReactNode, } from "react"; import { resolveAction, executeAction, type ActionBinding, type ActionHandler, type ActionConfirm, type ResolvedAction, } from "@json-render/core"; import { useStateStore } from "./state"; function generateUniqueId(): string { return crypto.randomUUID(); } function deepResolveValue( value: unknown, get: (path: string) => unknown, ): unknown { if (value === null || value === undefined) return value; if (value === "$id") { return generateUniqueId(); } if (typeof value === "object" && !Array.isArray(value)) { const obj = value as Record; const keys = Object.keys(obj); if (keys.length === 1 && typeof obj.$state === "string") { return get(obj.$state as string); } if (keys.length === 1 && "$id" in obj) { return generateUniqueId(); } } if (Array.isArray(value)) { return value.map((item) => deepResolveValue(item, get)); } if (typeof value === "object") { const resolved: Record = {}; for (const [key, val] of Object.entries(value as Record)) { resolved[key] = deepResolveValue(val, get); } return resolved; } return value; } export interface PendingConfirmation { action: ResolvedAction; handler: ActionHandler; resolve: () => void; reject: () => void; } export interface ActionContextValue { handlers: Record; loadingActions: Set; pendingConfirmation: PendingConfirmation | null; execute: (binding: ActionBinding) => Promise; confirm: () => void; cancel: () => void; registerHandler: (name: string, handler: ActionHandler) => void; } const ActionContext = createContext(null); export interface ActionProviderProps { handlers?: Record; navigate?: (path: string) => void; children: ReactNode; } export function ActionProvider({ handlers: initialHandlers = {}, navigate, children, }: ActionProviderProps) { const { state, get, set } = useStateStore(); const [handlers, setHandlers] = useState>(initialHandlers); const [loadingActions, setLoadingActions] = useState>(new Set()); const [pendingConfirmation, setPendingConfirmation] = useState(null); const registerHandler = useCallback( (name: string, handler: ActionHandler) => { setHandlers((prev) => ({ ...prev, [name]: handler })); }, [], ); const execute = useCallback( async (binding: ActionBinding) => { const resolved = resolveAction(binding, state); if (resolved.action === "setState" && resolved.params) { const statePath = resolved.params.statePath as string; const value = resolved.params.value; if (statePath) { set(statePath, value); } return; } if (resolved.action === "pushState" && resolved.params) { const statePath = resolved.params.statePath as string; const rawValue = resolved.params.value; if (statePath) { const resolvedValue = deepResolveValue(rawValue, get); const arr = (get(statePath) as unknown[] | undefined) ?? []; set(statePath, [...arr, resolvedValue]); const clearStatePath = resolved.params.clearStatePath as | string | undefined; if (clearStatePath) { set(clearStatePath, ""); } } return; } if (resolved.action === "removeState" && resolved.params) { const statePath = resolved.params.statePath as string; const index = resolved.params.index as number; if (statePath !== undefined && index !== undefined) { const arr = (get(statePath) as unknown[] | undefined) ?? []; set( statePath, arr.filter((_, i) => i !== index), ); } return; } const handler = handlers[resolved.action]; if (!handler) { console.warn(`No handler registered for action: ${resolved.action}`); return; } if (resolved.confirm) { return new Promise((resolve, reject) => { setPendingConfirmation({ action: resolved, handler, resolve: () => { setPendingConfirmation(null); resolve(); }, reject: () => { setPendingConfirmation(null); reject(new Error("Action cancelled")); }, }); }).then(async () => { setLoadingActions((prev) => new Set(prev).add(resolved.action)); try { await executeAction({ action: resolved, handler, setState: set, navigate, executeAction: async (name) => { const subBinding: ActionBinding = { action: name }; await execute(subBinding); }, }); } finally { setLoadingActions((prev) => { const next = new Set(prev); next.delete(resolved.action); return next; }); } }); } setLoadingActions((prev) => new Set(prev).add(resolved.action)); try { await executeAction({ action: resolved, handler, setState: set, navigate, executeAction: async (name) => { const subBinding: ActionBinding = { action: name }; await execute(subBinding); }, }); } finally { setLoadingActions((prev) => { const next = new Set(prev); next.delete(resolved.action); return next; }); } }, [state, handlers, get, set, navigate], ); const confirm = useCallback(() => { pendingConfirmation?.resolve(); }, [pendingConfirmation]); const cancel = useCallback(() => { pendingConfirmation?.reject(); }, [pendingConfirmation]); const value = useMemo( () => ({ handlers, loadingActions, pendingConfirmation, execute, confirm, cancel, registerHandler, }), [ handlers, loadingActions, pendingConfirmation, execute, confirm, cancel, registerHandler, ], ); return ( {children} ); } export function useActions(): ActionContextValue { const ctx = useContext(ActionContext); if (!ctx) { throw new Error("useActions must be used within an ActionProvider"); } return ctx; } export function useAction(binding: ActionBinding): { execute: () => Promise; isLoading: boolean; } { const { execute, loadingActions } = useActions(); const isLoading = loadingActions.has(binding.action); const executeAction = useCallback(() => execute(binding), [execute, binding]); return { execute: executeAction, isLoading }; } export interface ConfirmDialogProps { confirm: ActionConfirm; onConfirm: () => void; onCancel: () => void; } /** * No-op confirm dialog for email context. Emails are non-interactive, * so confirmations are not rendered. */ export function ConfirmDialog(_props: ConfirmDialogProps) { return null; } ================================================ FILE: packages/react-email/src/contexts/repeat-scope.tsx ================================================ import React, { createContext, useContext, type ReactNode } from "react"; export interface RepeatScopeValue { item: unknown; index: number; basePath: string; } const RepeatScopeContext = createContext(null); export function RepeatScopeProvider({ item, index, basePath, children, }: RepeatScopeValue & { children: ReactNode }) { return ( {children} ); } export function useRepeatScope(): RepeatScopeValue | null { return useContext(RepeatScopeContext); } ================================================ FILE: packages/react-email/src/contexts/state.tsx ================================================ import React, { createContext, useContext, useState, useCallback, useMemo, useEffect, useRef, type ReactNode, } from "react"; import { getByPath, setByPath, type StateModel } from "@json-render/core"; export interface StateContextValue { state: StateModel; get: (path: string) => unknown; set: (path: string, value: unknown) => void; update: (updates: Record) => void; } const StateContext = createContext(null); export interface StateProviderProps { initialState?: StateModel; onStateChange?: (path: string, value: unknown) => void; children: ReactNode; } export function StateProvider({ initialState = {}, onStateChange, children, }: StateProviderProps) { const [state, setStateInternal] = useState(initialState); const stateRef = useRef(state); stateRef.current = state; const initialStateJsonRef = useRef(JSON.stringify(initialState)); useEffect(() => { const newJson = JSON.stringify(initialState); if (newJson !== initialStateJsonRef.current) { initialStateJsonRef.current = newJson; if (initialState && Object.keys(initialState).length > 0) { setStateInternal((prev) => ({ ...prev, ...initialState })); } } }, [initialState]); const get = useCallback( (path: string) => getByPath(stateRef.current, path), [], ); const set = useCallback( (path: string, value: unknown) => { setStateInternal((prev) => { const next = { ...prev }; setByPath(next, path, value); return next; }); onStateChange?.(path, value); }, [onStateChange], ); const update = useCallback( (updates: Record) => { const entries = Object.entries(updates); setStateInternal((prev) => { const next = { ...prev }; for (const [path, value] of entries) { setByPath(next, path, value); } return next; }); for (const [path, value] of entries) { onStateChange?.(path, value); } }, [onStateChange], ); const value = useMemo( () => ({ state, get, set, update }), [state, get, set, update], ); return ( {children} ); } export function useStateStore(): StateContextValue { const ctx = useContext(StateContext); if (!ctx) { throw new Error("useStateStore must be used within a StateProvider"); } return ctx; } export function useStateValue(path: string): T | undefined { const { state } = useStateStore(); return getByPath(state, path) as T | undefined; } export function useStateBinding( path: string, ): [T | undefined, (value: T) => void] { const { state, set } = useStateStore(); const value = getByPath(state, path) as T | undefined; const setValue = useCallback( (newValue: T) => set(path, newValue), [path, set], ); return [value, setValue]; } ================================================ FILE: packages/react-email/src/contexts/validation.tsx ================================================ import React, { createContext, useContext, useState, useCallback, useMemo, type ReactNode, } from "react"; import { runValidation, type ValidationConfig, type ValidationFunction, type ValidationResult, } from "@json-render/core"; import { useStateStore } from "./state"; export interface FieldValidationState { touched: boolean; validated: boolean; result: ValidationResult | null; } export interface ValidationContextValue { customFunctions: Record; fieldStates: Record; validate: (path: string, config: ValidationConfig) => ValidationResult; touch: (path: string) => void; clear: (path: string) => void; validateAll: () => boolean; registerField: (path: string, config: ValidationConfig) => void; } const ValidationContext = createContext(null); export interface ValidationProviderProps { customFunctions?: Record; children: ReactNode; } function dynamicArgsEqual( a: Record | undefined, b: Record | undefined, ): boolean { if (a === b) return true; if (!a || !b) return false; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { const va = a[key]; const vb = b[key]; if (va === vb) continue; if ( typeof va === "object" && va !== null && typeof vb === "object" && vb !== null ) { const sa = (va as Record).$state; const sb = (vb as Record).$state; if (typeof sa === "string" && sa === sb) continue; } return false; } return true; } function validationConfigEqual( a: ValidationConfig, b: ValidationConfig, ): boolean { if (a === b) return true; if (a.validateOn !== b.validateOn) return false; const ac = a.checks ?? []; const bc = b.checks ?? []; if (ac.length !== bc.length) return false; for (let i = 0; i < ac.length; i++) { const ca = ac[i]!; const cb = bc[i]!; if (ca.type !== cb.type) return false; if (ca.message !== cb.message) return false; if (!dynamicArgsEqual(ca.args, cb.args)) return false; } return true; } export function ValidationProvider({ customFunctions = {}, children, }: ValidationProviderProps) { const { state } = useStateStore(); const [fieldStates, setFieldStates] = useState< Record >({}); const [fieldConfigs, setFieldConfigs] = useState< Record >({}); const registerField = useCallback( (path: string, config: ValidationConfig) => { setFieldConfigs((prev) => { const existing = prev[path]; if (existing && validationConfigEqual(existing, config)) { return prev; } return { ...prev, [path]: config }; }); }, [], ); const validate = useCallback( (path: string, config: ValidationConfig): ValidationResult => { const segments = path.split("/").filter(Boolean); let value: unknown = state; for (const seg of segments) { if (value != null && typeof value === "object") { value = (value as Record)[seg]; } else { value = undefined; break; } } const result = runValidation(config, { value, stateModel: state, customFunctions, }); setFieldStates((prev) => ({ ...prev, [path]: { touched: prev[path]?.touched ?? true, validated: true, result, }, })); return result; }, [state, customFunctions], ); const touch = useCallback((path: string) => { setFieldStates((prev) => ({ ...prev, [path]: { ...prev[path], touched: true, validated: prev[path]?.validated ?? false, result: prev[path]?.result ?? null, }, })); }, []); const clear = useCallback((path: string) => { setFieldStates((prev) => { const { [path]: _, ...rest } = prev; return rest; }); }, []); const validateAll = useCallback(() => { let allValid = true; for (const [path, config] of Object.entries(fieldConfigs)) { const result = validate(path, config); if (!result.valid) { allValid = false; } } return allValid; }, [fieldConfigs, validate]); const value = useMemo( () => ({ customFunctions, fieldStates, validate, touch, clear, validateAll, registerField, }), [ customFunctions, fieldStates, validate, touch, clear, validateAll, registerField, ], ); return ( {children} ); } export function useValidation(): ValidationContextValue { const ctx = useContext(ValidationContext); if (!ctx) { throw new Error("useValidation must be used within a ValidationProvider"); } return ctx; } export function useFieldValidation( path: string, config?: ValidationConfig, ): { state: FieldValidationState; validate: () => ValidationResult; touch: () => void; clear: () => void; errors: string[]; isValid: boolean; } { const { fieldStates, validate: validateField, touch: touchField, clear: clearField, registerField, } = useValidation(); React.useEffect(() => { if (config) { registerField(path, config); } }, [path, config, registerField]); const state = fieldStates[path] ?? { touched: false, validated: false, result: null, }; const validate = useCallback( () => validateField(path, config ?? { checks: [] }), [path, config, validateField], ); const touch = useCallback(() => touchField(path), [path, touchField]); const clear = useCallback(() => clearField(path), [path, clearField]); return { state, validate, touch, clear, errors: state.result?.errors ?? [], isValid: state.result?.valid ?? true, }; } ================================================ FILE: packages/react-email/src/contexts/visibility.tsx ================================================ import React, { createContext, useContext, useMemo, type ReactNode, } from "react"; import { evaluateVisibility, type VisibilityCondition, type VisibilityContext as CoreVisibilityContext, } from "@json-render/core"; import { useStateStore } from "./state"; export interface VisibilityContextValue { isVisible: (condition: VisibilityCondition | undefined) => boolean; ctx: CoreVisibilityContext; } const VisibilityContext = createContext(null); export interface VisibilityProviderProps { children: ReactNode; } export function VisibilityProvider({ children }: VisibilityProviderProps) { const { state } = useStateStore(); const ctx: CoreVisibilityContext = useMemo( () => ({ stateModel: state }), [state], ); const isVisible = useMemo( () => (condition: VisibilityCondition | undefined) => evaluateVisibility(condition, ctx), [ctx], ); const value = useMemo( () => ({ isVisible, ctx }), [isVisible, ctx], ); return ( {children} ); } export function useVisibility(): VisibilityContextValue { const ctx = useContext(VisibilityContext); if (!ctx) { throw new Error("useVisibility must be used within a VisibilityProvider"); } return ctx; } export function useIsVisible( condition: VisibilityCondition | undefined, ): boolean { const { isVisible } = useVisibility(); return isVisible(condition); } ================================================ FILE: packages/react-email/src/index.ts ================================================ // Schema export { schema, type ReactEmailSchema, type ReactEmailSpec } from "./schema"; // Core types (re-exported for convenience) export type { Spec } from "@json-render/core"; // Catalog-aware types export type { SetState, StateModel, ComponentContext, ComponentFn, Components, } from "./catalog-types"; // Contexts export { StateProvider, useStateStore, useStateValue, useStateBinding, type StateContextValue, type StateProviderProps, } from "./contexts/state"; export { VisibilityProvider, useVisibility, useIsVisible, type VisibilityContextValue, type VisibilityProviderProps, } from "./contexts/visibility"; export { ActionProvider, useActions, useAction, ConfirmDialog, type ActionContextValue, type ActionProviderProps, type PendingConfirmation, type ConfirmDialogProps, } from "./contexts/actions"; export { ValidationProvider, useValidation, useFieldValidation, type ValidationContextValue, type ValidationProviderProps, type FieldValidationState, } from "./contexts/validation"; export { RepeatScopeProvider, useRepeatScope, type RepeatScopeValue, } from "./contexts/repeat-scope"; // Renderer export { defineRegistry, type DefineRegistryResult, createRenderer, type CreateRendererProps, type ComponentMap, Renderer, JSONUIProvider, type ComponentRenderProps, type ComponentRenderer, type ComponentRegistry, type RendererProps, type JSONUIProviderProps, } from "./renderer"; // Standard components export { standardComponents } from "./components"; // Server-side render functions export { renderToHtml, renderToPlainText, type RenderOptions } from "./render"; // Catalog definitions export { standardComponentDefinitions, type StandardComponentDefinitions, type StandardComponentProps, } from "./catalog"; ================================================ FILE: packages/react-email/src/render.test.tsx ================================================ import { describe, it, expect } from "vitest"; import type { Spec } from "@json-render/core"; import { renderToHtml, renderToPlainText } from "./render"; import { examples } from "./__fixtures__/examples"; // ============================================================================= // Helpers // ============================================================================= /** Minimal valid email spec — Html > Head + Body */ function minimalSpec(bodyChildren: Spec["elements"] = {}): Spec { return { root: "html", elements: { html: { type: "Html", props: { lang: "en", dir: null }, children: ["head", "body"], }, head: { type: "Head", props: {}, children: [] }, body: { type: "Body", props: { style: null }, children: Object.keys(bodyChildren), }, ...bodyChildren, }, }; } function validateSpecStructure(spec: Spec) { const errors: string[] = []; if (!spec.elements[spec.root]) { errors.push(`Root element "${spec.root}" not found in elements`); } for (const [id, element] of Object.entries(spec.elements)) { if (element.children) { for (const childId of element.children) { if (!spec.elements[childId]) { errors.push( `Element "${id}" references child "${childId}" which does not exist`, ); } } } } const referencedIds = new Set(); referencedIds.add(spec.root); for (const element of Object.values(spec.elements)) { if (element.children) { for (const childId of element.children) { referencedIds.add(childId); } } } for (const id of Object.keys(spec.elements)) { if (!referencedIds.has(id)) { errors.push(`Element "${id}" is orphaned (not referenced by any parent)`); } } return errors; } // ============================================================================= // renderToHtml // ============================================================================= describe("renderToHtml", () => { it("renders a minimal spec to valid HTML", async () => { const html = await renderToHtml(minimalSpec()); expect(html).toContain(""); }); it("renders Text component content", async () => { const spec = minimalSpec({ text: { type: "Text", props: { text: "Hello World", style: null }, children: [], }, }); const html = await renderToHtml(spec); expect(html).toContain("Hello World"); }); it("renders Heading with correct tag level", async () => { const spec = minimalSpec({ heading: { type: "Heading", props: { text: "Title", as: "h1", style: null }, children: [], }, }); const html = await renderToHtml(spec); expect(html).toContain(" { const spec = minimalSpec({ link: { type: "Link", props: { text: "Click here", href: "https://example.com", style: null, }, children: [], }, }); const html = await renderToHtml(spec); expect(html).toContain("https://example.com"); expect(html).toContain("Click here"); }); it("renders Button with href and text", async () => { const spec = minimalSpec({ btn: { type: "Button", props: { text: "Get Started", href: "https://example.com/start", style: null, }, children: [], }, }); const html = await renderToHtml(spec); expect(html).toContain("https://example.com/start"); expect(html).toContain("Get Started"); }); it("renders Image with src and alt", async () => { const spec = minimalSpec({ img: { type: "Image", props: { src: "https://example.com/logo.png", alt: "Logo", width: 100, height: 50, style: null, }, children: [], }, }); const html = await renderToHtml(spec); expect(html).toContain("https://example.com/logo.png"); expect(html).toContain('alt="Logo"'); }); it("renders Hr element", async () => { const spec = minimalSpec({ divider: { type: "Hr", props: { style: { borderColor: "#eaeaea" } }, children: [], }, }); const html = await renderToHtml(spec); expect(html).toContain(" { const spec: Spec = { root: "html", elements: { html: { type: "Html", props: { lang: "en", dir: null }, children: ["head", "preview", "body"], }, head: { type: "Head", props: {}, children: [] }, preview: { type: "Preview", props: { text: "Inbox preview text" }, children: [], }, body: { type: "Body", props: { style: null }, children: [] }, }, }; const html = await renderToHtml(spec); expect(html).toContain("Inbox preview text"); }); it("renders nested Container > Section > Text", async () => { const spec = minimalSpec({ container: { type: "Container", props: { style: { maxWidth: "600px" } }, children: ["section"], }, section: { type: "Section", props: { style: null }, children: ["text"], }, text: { type: "Text", props: { text: "Nested content", style: null }, children: [], }, }); const html = await renderToHtml(spec); expect(html).toContain("Nested content"); }); it("renders Row and Column layout", async () => { const spec = minimalSpec({ section: { type: "Section", props: { style: null }, children: ["row"], }, row: { type: "Row", props: { style: null }, children: ["col1", "col2"], }, col1: { type: "Column", props: { style: { width: "50%" } }, children: ["left-text"], }, col2: { type: "Column", props: { style: { width: "50%" } }, children: ["right-text"], }, "left-text": { type: "Text", props: { text: "Left side", style: null }, children: [], }, "right-text": { type: "Text", props: { text: "Right side", style: null }, children: [], }, }); const html = await renderToHtml(spec); expect(html).toContain("Left side"); expect(html).toContain("Right side"); }); it("renders Markdown content", async () => { const spec = minimalSpec({ md: { type: "Markdown", props: { content: "# Hello\n\nThis is **bold** text.", markdownContainerStyles: null, markdownCustomStyles: null, }, children: [], }, }); const html = await renderToHtml(spec); expect(html).toContain("Hello"); expect(html).toContain("bold"); }); it("applies inline styles", async () => { const spec = minimalSpec({ text: { type: "Text", props: { text: "Styled", style: { color: "#ff0000", fontSize: "20px" }, }, children: [], }, }); const html = await renderToHtml(spec); expect(html).toContain("color:#ff0000"); expect(html).toContain("font-size:20px"); }); it("skips unknown component types gracefully", async () => { const spec = minimalSpec({ unknown: { type: "NonExistentComponent", props: {}, children: [], }, }); const html = await renderToHtml(spec); expect(html).toContain(" { const spec: Spec = { root: "html", elements: { html: { type: "Html", props: { lang: "en", dir: null }, children: ["head", "body"], }, head: { type: "Head", props: {}, children: [] }, body: { type: "Body", props: { style: null }, children: ["missing-child"], }, }, }; const html = await renderToHtml(spec); expect(html).toContain(" { it("extracts text content from spec", async () => { const spec = minimalSpec({ text: { type: "Text", props: { text: "Plain text content", style: null }, children: [], }, }); const text = await renderToPlainText(spec); expect(text).toContain("Plain text content"); }); it("extracts link text and URL", async () => { const spec = minimalSpec({ link: { type: "Link", props: { text: "Visit site", href: "https://example.com", style: null, }, children: [], }, }); const text = await renderToPlainText(spec); expect(text).toContain("Visit site"); expect(text).toContain("https://example.com"); }); it("returns string for minimal spec", async () => { const text = await renderToPlainText(minimalSpec()); expect(typeof text).toBe("string"); }); }); // ============================================================================= // Example specs — structural validation + render smoke tests // ============================================================================= describe("example specs", () => { it("has at least one example", () => { expect(examples.length).toBeGreaterThan(0); }); for (const example of examples) { describe(example.name, () => { it("has required metadata", () => { expect(example.name).toBeTruthy(); expect(example.label).toBeTruthy(); expect(example.description).toBeTruthy(); }); it("has a valid spec structure (no dangling children, no orphans)", () => { const errors = validateSpecStructure(example.spec); expect(errors).toEqual([]); }); it("root element is Html with Head and Body children", () => { const root = example.spec.elements[example.spec.root]; expect(root).toBeDefined(); expect(root!.type).toBe("Html"); const childTypes = root!.children?.map( (id) => example.spec.elements[id]?.type, ); expect(childTypes).toContain("Head"); expect(childTypes).toContain("Body"); }); it("renders to HTML without errors", async () => { const html = await renderToHtml(example.spec); expect(html).toBeTruthy(); expect(html).toContain(""); }); it("renders to plain text without errors", async () => { const text = await renderToPlainText(example.spec); expect(text).toBeTruthy(); expect(typeof text).toBe("string"); }); }); } }); ================================================ FILE: packages/react-email/src/render.tsx ================================================ import React from "react"; import { render } from "@react-email/render"; import type { Spec, UIElement } from "@json-render/core"; import { resolveElementProps, evaluateVisibility, getByPath, type PropResolutionContext, } from "@json-render/core"; import { standardComponents } from "./components/standard"; // Re-export the standard components for use in custom registries export { standardComponents }; export type RenderComponentRegistry = Record>; export interface RenderOptions { registry?: RenderComponentRegistry; includeStandard?: boolean; state?: Record; } const noopEmit = () => {}; function renderElement( elementKey: string, spec: Spec, registry: RenderComponentRegistry, stateModel: Record, repeatItem?: unknown, repeatIndex?: number, repeatBasePath?: string, ): React.ReactElement | null { const element = spec.elements[elementKey]; if (!element) return null; const ctx: PropResolutionContext = { stateModel, repeatItem, repeatIndex, repeatBasePath, }; if (element.visible !== undefined) { if (!evaluateVisibility(element.visible, ctx)) { return null; } } const resolvedProps = resolveElementProps( element.props as Record, ctx, ); const resolvedElement: UIElement = { ...element, props: resolvedProps }; const Component = registry[resolvedElement.type]; if (!Component) return null; if (resolvedElement.repeat) { const items = (getByPath(stateModel, resolvedElement.repeat.statePath) as | unknown[] | undefined) ?? []; const repeat = resolvedElement.repeat!; const fragments = items.map((item, index) => { const repeatKey = repeat.key; const key = repeatKey && typeof item === "object" && item !== null ? String((item as Record)[repeatKey] ?? index) : String(index); const childPath = `${repeat.statePath}/${index}`; const children = resolvedElement.children?.map((childKey) => renderElement( childKey, spec, registry, stateModel, item, index, childPath, ), ); return ( {children} ); }); return <>{fragments}; } const children = resolvedElement.children?.map((childKey) => renderElement( childKey, spec, registry, stateModel, repeatItem, repeatIndex, repeatBasePath, ), ); return ( {children && children.length > 0 ? children : undefined} ); } function buildDocument( spec: Spec, options: RenderOptions = {}, ): React.ReactElement { const { registry: customRegistry, includeStandard = true, state = {}, } = options; const mergedState: Record = { ...spec.state, ...state, }; const registry: RenderComponentRegistry = { ...(includeStandard ? standardComponents : {}), ...customRegistry, }; const root = renderElement(spec.root, spec, registry, mergedState); if (!root) { console.warn( `[json-render/react-email] Root element "${spec.root}" not found in spec.elements`, ); } return root ?? <>; } /** * Render a json-render spec to an HTML email string. * * This is a standalone server-side function that resolves the spec tree * without React hooks or contexts, making it safe to import in Next.js * route handlers and other server-only environments. */ export async function renderToHtml( spec: Spec, options?: RenderOptions, ): Promise { const document = buildDocument(spec, options); return render(document); } /** * Render a json-render spec to a plain text email string. */ export async function renderToPlainText( spec: Spec, options?: RenderOptions, ): Promise { const document = buildDocument(spec, options); return render(document, { plainText: true }); } ================================================ FILE: packages/react-email/src/renderer.tsx ================================================ import React, { type ComponentType, type ErrorInfo, type ReactNode, useCallback, useMemo, } from "react"; import type { UIElement, Spec, ActionBinding, Catalog, SchemaDefinition, } from "@json-render/core"; import { resolveElementProps, resolveBindings, resolveActionParam, evaluateVisibility, getByPath, type PropResolutionContext, type VisibilityContext as CoreVisibilityContext, } from "@json-render/core"; import type { Components, SetState, StateModel } from "./catalog-types"; import { useIsVisible, useVisibility } from "./contexts/visibility"; import { useActions } from "./contexts/actions"; import { useStateStore } from "./contexts/state"; import { StateProvider } from "./contexts/state"; import { VisibilityProvider } from "./contexts/visibility"; import { ActionProvider } from "./contexts/actions"; import { ValidationProvider } from "./contexts/validation"; import { standardComponents } from "./components/standard"; import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope"; // ============================================================================= // Types // ============================================================================= export interface ComponentRenderProps

> { element: UIElement; children?: ReactNode; emit: (event: string) => void; bindings?: Record; loading?: boolean; } export type ComponentRenderer

> = ComponentType< ComponentRenderProps

>; export type ComponentRegistry = Record>; export interface RendererProps { spec: Spec | null; registry?: ComponentRegistry; includeStandard?: boolean; loading?: boolean; fallback?: ComponentRenderer; } // ============================================================================= // ElementErrorBoundary // ============================================================================= interface ElementErrorBoundaryProps { elementType: string; children: ReactNode; } interface ElementErrorBoundaryState { hasError: boolean; } class ElementErrorBoundary extends React.Component< ElementErrorBoundaryProps, ElementErrorBoundaryState > { constructor(props: ElementErrorBoundaryProps) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(): ElementErrorBoundaryState { return { hasError: true }; } componentDidCatch(error: Error, info: ErrorInfo) { console.error( `[json-render/react-email] Rendering error in <${this.props.elementType}>:`, error, info.componentStack, ); } render() { if (this.state.hasError) { return null; } return this.props.children; } } // ============================================================================= // ElementRenderer // ============================================================================= interface ElementRendererProps { element: UIElement; spec: Spec; registry: ComponentRegistry; loading?: boolean; fallback?: ComponentRenderer; } const ElementRenderer = React.memo(function ElementRenderer({ element, spec, registry, loading, fallback, }: ElementRendererProps) { const repeatScope = useRepeatScope(); const { ctx } = useVisibility(); const { execute } = useActions(); const fullCtx: PropResolutionContext = useMemo( () => repeatScope ? { ...ctx, repeatItem: repeatScope.item, repeatIndex: repeatScope.index, repeatBasePath: repeatScope.basePath, } : ctx, [ctx, repeatScope], ); const isVisible = element.visible === undefined ? true : evaluateVisibility(element.visible, fullCtx); const onBindings = element.on; const emit = useCallback( (eventName: string) => { const binding = onBindings?.[eventName]; if (!binding) return; const actionBindings = Array.isArray(binding) ? binding : [binding]; for (const b of actionBindings) { if (!b.params) { execute(b); continue; } const resolved: Record = {}; for (const [key, val] of Object.entries(b.params)) { resolved[key] = resolveActionParam(val, fullCtx); } execute({ ...b, params: resolved }); } }, [onBindings, execute, fullCtx], ); if (!isVisible) { return null; } const rawProps = element.props as Record; const elementBindings = resolveBindings(rawProps, fullCtx); const resolvedProps = resolveElementProps(rawProps, fullCtx); const resolvedElement = resolvedProps !== element.props ? { ...element, props: resolvedProps } : element; const Component = registry[resolvedElement.type] ?? fallback; if (!Component) { console.warn( `[json-render/react-email] No renderer for component type: ${resolvedElement.type}`, ); return null; } const children = resolvedElement.repeat ? ( ) : ( resolvedElement.children?.map((childKey) => { const childElement = spec.elements[childKey]; if (!childElement) { if (!loading) { console.warn( `[json-render/react-email] Missing element "${childKey}" referenced as child of "${resolvedElement.type}".`, ); } return null; } return ( ); }) ); return ( {children} ); }); // ============================================================================= // RepeatChildren // ============================================================================= function RepeatChildren({ element, spec, registry, loading, fallback, }: { element: UIElement; spec: Spec; registry: ComponentRegistry; loading?: boolean; fallback?: ComponentRenderer; }) { const { state } = useStateStore(); const repeat = element.repeat!; const statePath = repeat.statePath; const items = (getByPath(state, statePath) as unknown[] | undefined) ?? []; return ( <> {items.map((itemValue, index) => { const key = repeat.key && typeof itemValue === "object" && itemValue !== null ? String( (itemValue as Record)[repeat.key] ?? index, ) : String(index); return ( {element.children?.map((childKey) => { const childElement = spec.elements[childKey]; if (!childElement) { if (!loading) { console.warn( `[json-render/react-email] Missing element "${childKey}" referenced as child of "${element.type}" (repeat).`, ); } return null; } return ( ); })} ); })} ); } // ============================================================================= // Renderer // ============================================================================= export function Renderer({ spec, registry: customRegistry, includeStandard = true, loading, fallback, }: RendererProps) { const registry: ComponentRegistry = useMemo( () => ({ ...(includeStandard ? standardComponents : {}), ...customRegistry, }), [customRegistry, includeStandard], ); if (!spec || !spec.root) { return null; } const rootElement = spec.elements[spec.root]; if (!rootElement) { return null; } return ( ); } // ============================================================================= // JSONUIProvider // ============================================================================= export interface JSONUIProviderProps { initialState?: Record; handlers?: Record< string, (params: Record) => Promise | unknown >; navigate?: (path: string) => void; validationFunctions?: Record< string, (value: unknown, args?: Record) => boolean >; onStateChange?: (path: string, value: unknown) => void; children: ReactNode; } export function JSONUIProvider({ initialState, handlers, navigate, validationFunctions, onStateChange, children, }: JSONUIProviderProps) { return ( {children} ); } // ============================================================================= // defineRegistry // ============================================================================= export interface DefineRegistryResult { registry: ComponentRegistry; } type DefineRegistryComponentFn = (ctx: { props: unknown; children?: React.ReactNode; emit: (event: string) => void; bindings?: Record; loading?: boolean; }) => React.ReactNode; export function defineRegistry( _catalog: C, options: { components?: Components; }, ): DefineRegistryResult { const registry: ComponentRegistry = {}; if (options.components) { for (const [name, componentFn] of Object.entries(options.components)) { registry[name] = ({ element, children, emit, bindings, loading, }: ComponentRenderProps) => { return (componentFn as DefineRegistryComponentFn)({ props: element.props, children, emit, bindings, loading, }); }; } } return { registry }; } // ============================================================================= // createRenderer // ============================================================================= export interface CreateRendererProps { spec: Spec | null; state?: Record; onAction?: (actionName: string, params?: Record) => void; onStateChange?: (path: string, value: unknown) => void; loading?: boolean; fallback?: ComponentRenderer; } export type ComponentMap< TComponents extends Record, > = { [K in keyof TComponents]: ComponentType< ComponentRenderProps< TComponents[K]["props"] extends { _output: infer O } ? O : Record > >; }; export function createRenderer< TDef extends SchemaDefinition, TCatalog extends { components: Record }, >( catalog: Catalog, components: ComponentMap, ): ComponentType { const registry: ComponentRegistry = components as unknown as ComponentRegistry; return function CatalogRenderer({ spec, state, onAction, onStateChange, loading, fallback, }: CreateRendererProps) { const actionHandlers = onAction ? new Proxy( {} as Record< string, (params: Record) => void | Promise >, { get: (_target, prop: string) => { return (params: Record) => onAction(prop, params); }, has: () => true, }, ) : undefined; return ( ); }; } ================================================ FILE: packages/react-email/src/schema.ts ================================================ import { defineSchema } from "@json-render/core"; /** * The schema for @json-render/react-email * * Defines: * - Spec: A flat tree of elements with keys, types, props, and children references * - Catalog: Components with props schemas * * Reuses the same { root, elements } spec format as the React and React Native renderers. */ export const schema = defineSchema( (s) => ({ spec: s.object({ root: s.string(), elements: s.record( s.object({ type: s.ref("catalog.components"), props: s.propsOf("catalog.components"), children: s.array(s.string()), visible: s.any(), }), ), }), catalog: s.object({ components: s.map({ props: s.zod(), slots: s.array(s.string()), description: s.string(), example: s.any(), }), }), }), { defaultRules: [ "The root element MUST be an Html component. Its children MUST include Head and Body components.", "Body should contain a Container component to constrain width (typically 600px max for email clients).", "All styles MUST be inline. Email clients strip ================================================ FILE: packages/svelte/src/ConfirmDialogManager.svelte ================================================ {#if pendingConfirmation?.action.confirm} {/if} ================================================ FILE: packages/svelte/src/ElementRenderer.svelte ================================================ {#if isVisible && Component} { console.error( `[json-render] Rendering error in <${resolvedElement.type}>:`, error, ); }}> {#if resolvedElement.repeat} {:else if resolvedElement.children} {#each resolvedElement.children as childKey (childKey)} {#if spec.elements[childKey]} {:else if !loading} {console.warn( `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`, )} {/if} {/each} {/if} {#snippet failed()} {/snippet} {/if} ================================================ FILE: packages/svelte/src/JsonUIProvider.svelte ================================================ {@render children()} ================================================ FILE: packages/svelte/src/Renderer.svelte ================================================ {#if spec && rootElement} {/if} ================================================ FILE: packages/svelte/src/RendererWithProvider.test.svelte ================================================ ================================================ FILE: packages/svelte/src/RepeatChildren.svelte ================================================ {#each items as itemValue, index (element.repeat?.key && typeof itemValue === "object" && itemValue !== null ? String((itemValue as any)[element.repeat.key] ?? index) : String(index))} {@const basePath = `${element.repeat!.statePath}/${index}`} {#if element.children} {#each element.children as childKey (childKey)} {#if spec.elements[childKey]} {:else if !loading} {console.warn( `[json-render] Missing element "${childKey}" referenced as child of "${element.type}". This element will not render.`, )} {/if} {/each} {/if} {/each} ================================================ FILE: packages/svelte/src/TestButton.svelte ================================================ ================================================ FILE: packages/svelte/src/TestContainer.svelte ================================================

{#if props.title}

{props.title}

{/if} {#if children} {@render children()} {/if}
================================================ FILE: packages/svelte/src/TestText.svelte ================================================ {props.text ?? ""} ================================================ FILE: packages/svelte/src/catalog-types.ts ================================================ import type { Component, Snippet } from "svelte"; import type { Catalog, InferCatalogComponents, InferCatalogActions, InferComponentProps, InferActionParams, StateModel, } from "@json-render/core"; export type { StateModel }; // ============================================================================= // State Types // ============================================================================= /** * State setter function for updating application state */ export type SetState = ( updater: (prev: Record) => Record, ) => void; // ============================================================================= // Component Types // ============================================================================= /** * Handle returned by the `on()` function for a specific event. * Provides metadata about the event binding and a method to fire it. */ export interface EventHandle { /** Fire the event (resolve action bindings) */ emit: () => void; /** Whether any binding requested preventDefault */ shouldPreventDefault: boolean; /** Whether any handler is bound to this event */ bound: boolean; } /** * Catalog-agnostic base type for component render function arguments. * Use this when building reusable component libraries. */ export interface BaseComponentProps

> { props: P; children?: Snippet; /** Simple event emitter (shorthand). Fires the event and returns void. */ emit: (event: string) => void; /** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */ on: (event: string) => EventHandle; /** * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions. * Maps prop name → absolute state path for write-back. */ bindings?: Record; loading?: boolean; } /** * Context passed to component render functions */ export interface ComponentContext< C extends Catalog, K extends keyof InferCatalogComponents, > extends BaseComponentProps> {} /** * Component render function type for Svelte */ export type ComponentFn< C extends Catalog, K extends keyof InferCatalogComponents, > = Component>>; /** * Registry of Svelte component constructors for a catalog */ export type Components = { [K in keyof InferCatalogComponents]: ComponentFn; }; // ============================================================================= // Action Types // ============================================================================= /** * Action handler function type */ export type ActionFn< C extends Catalog, K extends keyof InferCatalogActions, > = ( params: InferActionParams | undefined, setState: SetState, state: StateModel, ) => Promise; /** * Registry of all action handlers for a catalog */ export type Actions = { [K in keyof InferCatalogActions]: ActionFn; }; ================================================ FILE: packages/svelte/src/contexts/ActionProvider.svelte ================================================ {@render children?.()} ================================================ FILE: packages/svelte/src/contexts/FunctionsContextProvider.svelte ================================================ {@render children?.()} ================================================ FILE: packages/svelte/src/contexts/RepeatScopeProvider.svelte ================================================ {@render children()} ================================================ FILE: packages/svelte/src/contexts/StateProvider.svelte ================================================ {@render children?.()} ================================================ FILE: packages/svelte/src/contexts/ValidationProvider.svelte ================================================ {@render children?.()} ================================================ FILE: packages/svelte/src/contexts/VisibilityProvider.svelte ================================================ {@render children?.()} ================================================ FILE: packages/svelte/src/contexts/actions.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { mount, unmount } from "svelte"; import StateProvider, { getStateContext } from "./StateProvider.svelte"; import ActionProvider, { getActionContext } from "./ActionProvider.svelte"; import ValidationProvider, { getValidationContext, } from "./ValidationProvider.svelte"; function component( runTest: () => Promise, options: { initialState?: Record; handlers?: Record< string, (params: Record) => Promise | unknown >; withValidation?: boolean; } = {}, ) { return async () => { let promise: Promise; const c = mount( ((_anchor: any) => { (StateProvider as any)(_anchor, { initialState: options.initialState ?? {}, children: ((_inner: any) => { if (options.withValidation) { (ValidationProvider as any)(_inner, { children: ((__inner: any) => { (ActionProvider as any)(__inner, { handlers: options.handlers ?? {}, children: (() => { promise = runTest(); }) as any, }); }) as any, }); return; } (ActionProvider as any)(_inner, { handlers: options.handlers ?? {}, children: (() => { promise = runTest(); }) as any, }); }) as any, }); }) as any, { target: document.body }, ); await promise!; // eslint-disable-line @typescript-eslint/no-non-null-assertion unmount(c); }; } describe("createActionContext", () => { it( "executes built-in setState action", component( async () => { const stateCtx = getStateContext(); const actionCtx = getActionContext(); await actionCtx.execute({ action: "setState", params: { statePath: "/count", value: 5 }, }); expect(stateCtx.state.count).toBe(5); }, { initialState: { count: 0 } }, ), ); it( "executes built-in pushState action", component( async () => { const stateCtx = getStateContext(); const actionCtx = getActionContext(); await actionCtx.execute({ action: "pushState", params: { statePath: "/items", value: "c" }, }); expect(stateCtx.state.items).toEqual(["a", "b", "c"]); }, { initialState: { items: ["a", "b"] } }, ), ); it( "pushState creates array if missing", component(async () => { const stateCtx = getStateContext(); const actionCtx = getActionContext(); await actionCtx.execute({ action: "pushState", params: { statePath: "/newList", value: "first" }, }); expect(stateCtx.get("/newList")).toEqual(["first"]); }), ); it( "executes built-in removeState action", component( async () => { const stateCtx = getStateContext(); const actionCtx = getActionContext(); await actionCtx.execute({ action: "removeState", params: { statePath: "/items", index: 1 }, }); expect(stateCtx.state.items).toEqual(["a", "c"]); }, { initialState: { items: ["a", "b", "c"] } }, ), ); it( "executes push navigation action", component( async () => { const stateCtx = getStateContext(); const actionCtx = getActionContext(); await actionCtx.execute({ action: "push", params: { screen: "settings" }, }); expect(stateCtx.get("/currentScreen")).toBe("settings"); expect(stateCtx.get("/navStack")).toEqual(["home"]); }, { initialState: { currentScreen: "home" } }, ), ); it( "executes pop navigation action", component( async () => { const stateCtx = getStateContext(); const actionCtx = getActionContext(); await actionCtx.execute({ action: "pop" }); expect(stateCtx.get("/currentScreen")).toBe("home"); expect(stateCtx.get("/navStack")).toEqual([]); }, { initialState: { currentScreen: "settings", navStack: ["home"] } }, ), ); it( "executes custom handlers", (() => { const customHandler = vi.fn().mockResolvedValue(undefined); return component( async () => { const actionCtx = getActionContext(); await actionCtx.execute({ action: "myAction", params: { foo: "bar" }, }); expect(customHandler).toHaveBeenCalledWith({ foo: "bar" }); }, { handlers: { myAction: customHandler } }, ); })(), ); it( "warns when no handler registered", component(async () => { const actionCtx = getActionContext(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); await actionCtx.execute({ action: "unknownAction" }); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("unknownAction"), ); warnSpy.mockRestore(); }), ); it( "tracks loading state for actions", (() => { let resolveHandler: () => void; const slowHandler = vi.fn( () => new Promise((resolve) => { resolveHandler = resolve; }), ); return component( async () => { const actionCtx = getActionContext(); const executePromise = actionCtx.execute({ action: "slowAction" }); expect(actionCtx.loadingActions.has("slowAction")).toBe(true); resolveHandler!(); await executePromise; expect(actionCtx.loadingActions.has("slowAction")).toBe(false); }, { handlers: { slowAction: slowHandler, }, }, ); })(), ); it( "allows registering handlers dynamically", component(async () => { const actionCtx = getActionContext(); const dynamicHandler = vi.fn(); actionCtx.registerHandler("dynamicAction", dynamicHandler); await actionCtx.execute({ action: "dynamicAction", params: { x: 1 } }); expect(dynamicHandler).toHaveBeenCalledWith({ x: 1 }); }), ); it( "executes validateForm and writes result to /formValidation", component( async () => { const stateCtx = getStateContext(); const actionCtx = getActionContext(); const validationCtx = getValidationContext(); validationCtx.registerField("/form/email", { checks: [{ type: "required", message: "Required" }], }); await actionCtx.execute({ action: "validateForm" }); expect(stateCtx.get("/formValidation")).toEqual({ valid: false, errors: { "/form/email": ["Required"] }, }); }, { withValidation: true }, ), ); it( "validateForm defaults to warning when validation context is missing", component(async () => { const actionCtx = getActionContext(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); await actionCtx.execute({ action: "validateForm" }); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("validateForm action was dispatched"), ); warnSpy.mockRestore(); }), ); }); ================================================ FILE: packages/svelte/src/contexts/state.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { mount, unmount } from "svelte"; import { createStateStore } from "@json-render/core"; import StateProvider, { getStateContext } from "./StateProvider.svelte"; function component( runTest: () => void, props: { initialState?: Record; onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; store?: ReturnType; } = {}, ) { return () => { const c = mount( ((_anchor: any) => { (StateProvider as any)(_anchor, { ...props, children: (() => { runTest(); }) as any, }); }) as any, { target: document.body }, ); unmount(c); }; } describe("StateProvider", () => { it( "provides initial state to consumers", component( () => { const ctx = getStateContext(); expect(ctx.state).toEqual({ user: { name: "John" } }); }, { initialState: { user: { name: "John" } } }, ), ); it( "provides empty object when no initial state", component(() => { const ctx = getStateContext(); expect(ctx.state).toEqual({}); }), ); }); describe("StateContext.get", () => { it( "retrieves values by path", component( () => { const ctx = getStateContext(); expect(ctx.get("/user/name")).toBe("John"); expect(ctx.get("/user/age")).toBe(30); }, { initialState: { user: { name: "John", age: 30 } } }, ), ); it( "returns undefined for missing path", component( () => { const ctx = getStateContext(); expect(ctx.get("/user/email")).toBeUndefined(); expect(ctx.get("/nonexistent")).toBeUndefined(); }, { initialState: { user: { name: "John" } } }, ), ); }); describe("StateContext.set", () => { it( "updates values at path", component( () => { const ctx = getStateContext(); ctx.set("/count", 5); expect(ctx.state.count).toBe(5); }, { initialState: { count: 0 } }, ), ); it( "creates nested paths", component(() => { const ctx = getStateContext(); ctx.set("/user/name", "Jane"); expect(ctx.get("/user/name")).toBe("Jane"); }), ); it( "calls onStateChange callback with change entries", component( () => { const ctx = getStateContext(); ctx.set("/value", 2); }, { initialState: { value: 1 }, onStateChange: vi.fn((changes) => { expect(changes).toEqual([{ path: "/value", value: 2 }]); }), }, ), ); }); describe("StateContext.update", () => { it( "handles multiple values at once", component( () => { const ctx = getStateContext(); ctx.update({ "/a": 10, "/b": 20 }); expect(ctx.state.a).toBe(10); expect(ctx.state.b).toBe(20); }, { initialState: { a: 1, b: 2 } }, ), ); it( "calls onStateChange once with all changed updates", component( () => { const ctx = getStateContext(); ctx.update({ "/x": 1, "/y": 2 }); }, { initialState: { x: 0, y: 0 }, onStateChange: vi.fn((changes) => { expect(changes).toEqual([ { path: "/x", value: 1 }, { path: "/y", value: 2 }, ]); }), }, ), ); }); describe("StateContext nested paths", () => { it( "handles deeply nested state paths", component( () => { const ctx = getStateContext(); expect(ctx.get("/app/settings/theme")).toBe("light"); expect(ctx.get("/app/settings/notifications/enabled")).toBe(true); ctx.set("/app/settings/theme", "dark"); expect(ctx.get("/app/settings/theme")).toBe("dark"); }, { initialState: { app: { settings: { theme: "light", notifications: { enabled: true }, }, }, }, }, ), ); it( "handles array indices in paths", component( () => { const ctx = getStateContext(); expect(ctx.get("/items/0")).toBe("a"); expect(ctx.get("/items/1")).toBe("b"); ctx.set("/items/1", "B"); expect(ctx.get("/items/1")).toBe("B"); }, { initialState: { items: ["a", "b", "c"] } }, ), ); }); describe("controlled mode", () => { it( "reads and writes through external StateStore", (() => { const store = createStateStore({ count: 1 }); const onStateChange = vi.fn(); return component( () => { const ctx = getStateContext(); expect(ctx.get("/count")).toBe(1); ctx.set("/count", 2); expect(store.get("/count")).toBe(2); expect(onStateChange).not.toHaveBeenCalled(); }, { store, onStateChange }, ); })(), ); }); ================================================ FILE: packages/svelte/src/contexts/visibility.test.ts ================================================ import { describe, it, expect } from "vitest"; import { mount, unmount } from "svelte"; import StateProvider, { getStateContext } from "./StateProvider.svelte"; import VisibilityProvider, { getVisibilityContext, } from "./VisibilityProvider.svelte"; function component( runTest: () => void, initialState: Record = {}, ) { return () => { const c = mount( ((_anchor: any) => { (StateProvider as any)(_anchor, { initialState, children: ((_inner: any) => { (VisibilityProvider as any)(_inner, { children: (() => { runTest(); }) as any, }); }) as any, }); }) as any, { target: document.body }, ); unmount(c); }; } describe("VisibilityProvider", () => { it( "provides isVisible function", component(() => { const visCtx = getVisibilityContext(); expect(typeof visCtx.isVisible).toBe("function"); }), ); it( "provides visibility context", component( () => { const visCtx = getVisibilityContext(); expect(visCtx.ctx).toBeDefined(); expect(visCtx.ctx.stateModel).toEqual({ value: true }); }, { value: true }, ), ); }); describe("isVisible", () => { it( "returns true for undefined condition", component(() => { const visCtx = getVisibilityContext(); expect(visCtx.isVisible(undefined)).toBe(true); }), ); it( "returns true for true condition", component(() => { const visCtx = getVisibilityContext(); expect(visCtx.isVisible(true)).toBe(true); }), ); it( "returns false for false condition", component(() => { const visCtx = getVisibilityContext(); expect(visCtx.isVisible(false)).toBe(false); }), ); it( "evaluates $state conditions against data", component( () => { const stateCtx = getStateContext(); const visCtx = getVisibilityContext(); expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(true); stateCtx.set("/isLoggedIn", false); expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(false); }, { isLoggedIn: true }, ), ); it( "evaluates equality conditions", component( () => { const visCtx = getVisibilityContext(); expect(visCtx.isVisible({ $state: "/tab", eq: "home" })).toBe(true); expect(visCtx.isVisible({ $state: "/tab", eq: "settings" })).toBe( false, ); }, { tab: "home" }, ), ); it( "evaluates array conditions (implicit AND)", component( () => { const visCtx = getVisibilityContext(); expect(visCtx.isVisible([{ $state: "/a" }, { $state: "/b" }])).toBe( true, ); expect(visCtx.isVisible([{ $state: "/a" }, { $state: "/c" }])).toBe( false, ); }, { a: true, b: true, c: false }, ), ); it( "evaluates $and conditions", component( () => { const visCtx = getVisibilityContext(); expect( visCtx.isVisible({ $and: [{ $state: "/x" }, { $state: "/y" }] }), ).toBe(false); }, { x: true, y: false }, ), ); it( "evaluates $or conditions", component( () => { const visCtx = getVisibilityContext(); expect( visCtx.isVisible({ $or: [{ $state: "/x" }, { $state: "/y" }] }), ).toBe(true); }, { x: true, y: false }, ), ); }); ================================================ FILE: packages/svelte/src/index.ts ================================================ // ============================================================================= // Contexts // ============================================================================= export { default as StateProvider, getStateContext, getStateValue, getBoundProp, type StateContext, } from "./contexts/StateProvider.svelte"; export { default as VisibilityProvider, getVisibilityContext, isVisible, type VisibilityContext, } from "./contexts/VisibilityProvider.svelte"; export { default as ActionProvider, getActionContext, getAction, type ActionContext, type PendingConfirmation, } from "./contexts/ActionProvider.svelte"; export { default as ValidationProvider, getValidationContext, getOptionalValidationContext, getFieldValidation, type ValidationContext, type FieldValidationState, } from "./contexts/ValidationProvider.svelte"; export { default as RepeatScopeProvider, getRepeatScope, type RepeatScopeValue, } from "./contexts/RepeatScopeProvider.svelte"; export { default as FunctionsContextProvider, getFunctions, type FunctionsContext, } from "./contexts/FunctionsContextProvider.svelte"; // ============================================================================= // Schema // ============================================================================= export { schema, type SvelteSchema, type SvelteSpec } from "./schema.js"; // ============================================================================= // Catalog Types // ============================================================================= export type { EventHandle, BaseComponentProps, SetState, StateModel, ComponentContext, ComponentFn, Components, ActionFn, Actions, } from "./catalog-types.js"; // ============================================================================= // Utilities // ============================================================================= export { flatToTree, buildSpecFromParts, getTextFromParts, type DataPart, } from "./utils.svelte.js"; // ============================================================================= // Streaming // ============================================================================= export { createUIStream, createChatUI, type UIStreamOptions, type UIStreamReturn, type UIStreamState, type ChatUIOptions, type ChatUIReturn, type ChatMessage, type TokenUsage, } from "./streaming.svelte.js"; // ============================================================================= // Registry // ============================================================================= export { defineRegistry, createRenderer, type DefineRegistryResult, type ComponentRenderer, type ComponentRegistry, } from "./renderer.js"; export { default as Renderer, type RendererProps } from "./Renderer.svelte"; export { default as CatalogRenderer, type CatalogRendererProps, } from "./CatalogRenderer.svelte"; export { default as JsonUIProvider, type JSONUIProviderProps, } from "./JsonUIProvider.svelte"; export { default as ConfirmDialog } from "./ConfirmDialog.svelte"; export { default as ConfirmDialogManager } from "./ConfirmDialogManager.svelte"; // ============================================================================= // Re-exports from core // ============================================================================= export type { Spec, UIElement, ActionBinding, ActionHandler, } from "@json-render/core"; ================================================ FILE: packages/svelte/src/renderer.test.ts ================================================ import { describe, it, expect, afterEach } from "vitest"; import { render, cleanup } from "@testing-library/svelte"; import type { Spec } from "@json-render/core"; import RendererWithProvider from "./RendererWithProvider.test.svelte"; import TestContainer from "./TestContainer.svelte"; import TestText from "./TestText.svelte"; import TestButton from "./TestButton.svelte"; import { defineRegistry } from "./renderer.js"; describe("Renderer", () => { afterEach(() => { cleanup(); }); const { registry } = defineRegistry(null as any, { components: { Container: TestContainer, Text: TestText, Button: TestButton, }, }); function mountRenderer( spec: Spec | null, options: { loading?: boolean } = {}, ) { return render(RendererWithProvider, { props: { spec, registry, loading: options.loading ?? false, initialState: spec?.state ?? {}, }, }); } it("renders nothing for null spec", () => { const { container } = mountRenderer(null); // Should have no content rendered from Renderer expect(container.querySelector(".test-container")).toBeNull(); expect(container.querySelector(".test-text")).toBeNull(); }); it("renders nothing for spec with empty root", () => { const spec: Spec = { root: "", elements: {} }; const { container } = mountRenderer(spec); expect(container.querySelector(".test-container")).toBeNull(); }); it("renders a single element", () => { const spec: Spec = { root: "text1", elements: { text1: { type: "Text", props: { text: "Hello World" }, children: [], }, }, }; const { container } = mountRenderer(spec); const textEl = container.querySelector(".test-text"); expect(textEl).not.toBeNull(); expect(textEl?.textContent).toBe("Hello World"); }); it("renders nested elements", () => { const spec: Spec = { root: "container", elements: { container: { type: "Container", props: { title: "My Container" }, children: ["text1", "text2"], }, text1: { type: "Text", props: { text: "First" }, children: [], }, text2: { type: "Text", props: { text: "Second" }, children: [], }, }, }; const { container } = mountRenderer(spec); const containerEl = container.querySelector(".test-container"); expect(containerEl).not.toBeNull(); expect(containerEl?.querySelector("h2")?.textContent).toBe("My Container"); const texts = container.querySelectorAll(".test-text"); expect(texts).toHaveLength(2); expect(texts[0]?.textContent).toBe("First"); expect(texts[1]?.textContent).toBe("Second"); }); it("renders deeply nested elements", () => { const spec: Spec = { root: "outer", elements: { outer: { type: "Container", props: { title: "Outer" }, children: ["inner"], }, inner: { type: "Container", props: { title: "Inner" }, children: ["text"], }, text: { type: "Text", props: { text: "Deep text" }, children: [], }, }, }; const { container } = mountRenderer(spec); const containers = container.querySelectorAll(".test-container"); expect(containers).toHaveLength(2); const text = container.querySelector(".test-text"); expect(text?.textContent).toBe("Deep text"); }); it("passes loading prop to components", () => { const spec: Spec = { root: "container", elements: { container: { type: "Container", props: {}, children: [], }, }, }; const { container } = mountRenderer(spec, { loading: true }); const containerEl = container.querySelector(".test-container"); expect(containerEl?.getAttribute("data-loading")).toBe("true"); }); it("renders nothing for unknown component types without fallback", () => { const spec: Spec = { root: "unknown", elements: { unknown: { type: "UnknownType", props: {}, children: [], }, }, }; const { container } = mountRenderer(spec); // No elements should be rendered for unknown type expect(container.querySelector(".test-container")).toBeNull(); expect(container.querySelector(".test-text")).toBeNull(); }); it("skips missing child elements gracefully", () => { const spec: Spec = { root: "container", elements: { container: { type: "Container", props: { title: "Parent" }, children: ["existing", "missing"], }, existing: { type: "Text", props: { text: "I exist" }, children: [], }, // "missing" element is not defined }, }; const { container } = mountRenderer(spec); const containerEl = container.querySelector(".test-container"); expect(containerEl).not.toBeNull(); const texts = container.querySelectorAll(".test-text"); expect(texts).toHaveLength(1); expect(texts[0]?.textContent).toBe("I exist"); }); }); ================================================ FILE: packages/svelte/src/renderer.ts ================================================ import type { Catalog, ComputedFunction, SchemaDefinition, Spec, StateStore, UIElement, } from "@json-render/core"; import type { Component, Snippet } from "svelte"; import type { BaseComponentProps, EventHandle, SetState, StateModel, } from "./catalog-types.js"; import CatalogRenderer from "./CatalogRenderer.svelte"; /** * Props passed to component renderers */ export interface ComponentRenderProps

> { /** The element being rendered */ element: UIElement; /** Rendered children snippet */ children?: Snippet; /** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. */ emit: (event: string) => void; /** Get an event handle with metadata */ on: (event: string) => EventHandle; /** * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions. * Maps prop name → absolute state path for write-back. */ bindings?: Record; /** Whether the parent is loading */ loading?: boolean; } /** * Component renderer type - a Svelte component that receives ComponentRenderProps */ export type ComponentRenderer

> = Component< ComponentRenderProps

>; /** * Registry of component renderers. * Maps component type names to Svelte components. */ export type ComponentRegistry = Record>; /** * Action handler function for defineRegistry */ type DefineRegistryActionFn = ( params: Record | undefined, setState: SetState, state: StateModel, ) => Promise; /** * Result returned by defineRegistry */ export interface DefineRegistryResult { /** Component registry for Renderer */ registry: ComponentRegistry; /** * Create ActionProvider-compatible handlers. */ handlers: ( getSetState: () => SetState | undefined, getState: () => StateModel, ) => Record) => Promise>; /** * Execute an action by name imperatively */ executeAction: ( actionName: string, params: Record | undefined, setState: SetState, state?: StateModel, ) => Promise; } /** * Create a registry from a catalog with Svelte components and/or actions. * * Components must accept `BaseComponentProps` as their props interface. * * @example * ```ts * import { defineRegistry } from "@json-render/svelte"; * import Card from "./components/Card.svelte"; * import Button from "./components/Button.svelte"; * import { myCatalog } from "./catalog"; * * const { registry, handlers } = defineRegistry(myCatalog, { * components: { * Card, * Button, * }, * actions: { * submit: async (params, setState) => { * // handle action * }, * }, * }); * ``` */ export function defineRegistry< C extends Catalog, TComponents extends Record>>, >( _catalog: C, options: { /** Svelte components that accept BaseComponentProps */ components?: TComponents; /** Action handlers */ actions?: Record; }, ): DefineRegistryResult { const registry: ComponentRegistry = {}; if (options.components) { for (const [name, componentFn] of Object.entries(options.components)) { registry[name] = (_, props) => (componentFn as Component>)(_, { get props() { return props.element.props; }, get children() { return props.children; }, get emit() { return props.emit; }, get on() { return props.on; }, get bindings() { return props.bindings; }, get loading() { return props.loading; }, }); } } // Build action helpers const actionMap = options.actions ? (Object.entries(options.actions) as Array< [string, DefineRegistryActionFn] >) : []; const handlers = ( getSetState: () => SetState | undefined, getState: () => StateModel, ): Record) => Promise> => { const result: Record< string, (params: Record) => Promise > = {}; for (const [name, actionFn] of actionMap) { result[name] = async (params) => { const setState = getSetState(); const state = getState(); if (setState) { await actionFn(params, setState, state); } }; } return result; }; const executeAction = async ( actionName: string, params: Record | undefined, setState: SetState, state: StateModel = {}, ): Promise => { const entry = actionMap.find(([name]) => name === actionName); if (entry) { await entry[1](params, setState, state); } else { console.warn(`Unknown action: ${actionName}`); } }; return { registry, handlers, executeAction }; } // ============================================================================ // createRenderer // ============================================================================ /** * Props for renderers created with createRenderer */ export interface CreateRendererProps { spec: Spec | null; store?: StateStore; state?: Record; onAction?: (actionName: string, params?: Record) => void; onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; /** Named functions for `$computed` expressions in props */ functions?: Record; loading?: boolean; fallback?: Component; } /** * Component map type — maps component names to Svelte components */ export type ComponentMap< TComponents extends Record, > = { [K in keyof TComponents]: Component; }; /** * Create a renderer from a catalog * * @example * ```typescript * const DashboardRenderer = createRenderer(dashboardCatalog, { * Card, * Metric, * }); * * // Usage in template * * ``` */ export function createRenderer< TDef extends SchemaDefinition, TCatalog extends { components: Record }, >( _catalog: Catalog, components: ComponentMap, ): Component { const registry: ComponentRegistry = components as unknown as ComponentRegistry; return (_, props: CreateRendererProps) => CatalogRenderer(_, { registry, get spec() { return props.spec; }, get store() { return props.store; }, get state() { return props.state; }, get onAction() { return props.onAction; }, get onStateChange() { return props.onStateChange; }, get functions() { return props.functions; }, get loading() { return props.loading; }, get fallback() { return props.fallback; }, }); } ================================================ FILE: packages/svelte/src/schema.ts ================================================ import { defineSchema } from "@json-render/core"; /** * The schema for @json-render/svelte * * Defines: * - Spec: A flat tree of elements with keys, types, props, and children references * - Catalog: Components with props schemas, and optional actions */ export const schema = defineSchema( (s) => ({ // What the AI-generated SPEC looks like spec: s.object({ /** Root element key */ root: s.string(), /** Flat map of elements by key */ elements: s.record( s.object({ /** Component type from catalog */ type: s.ref("catalog.components"), /** Component props */ props: s.propsOf("catalog.components"), /** Child element keys (flat reference) */ children: s.array(s.string()), /** Visibility condition */ visible: s.any(), }), ), }), // What the CATALOG must provide catalog: s.object({ /** Component definitions */ components: s.map({ /** Zod schema for component props */ props: s.zod(), /** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */ slots: s.array(s.string()), /** Description for AI generation hints */ description: s.string(), /** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */ example: s.any(), }), /** Action definitions (optional) */ actions: s.map({ /** Zod schema for action params */ params: s.zod(), /** Description for AI generation hints */ description: s.string(), }), }), }), { builtInActions: [ { name: "setState", description: "Update a value in the state model at the given statePath. Params: { statePath: string, value: any }", }, { name: "pushState", description: '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.', }, { name: "removeState", description: "Remove an item from an array in state by index. Params: { statePath: string, index: number }", }, { name: "validateForm", description: "Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record }.", }, ], defaultRules: [ // Element integrity "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.", "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.", // Field placement 'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.', 'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.', // State and data "When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).", 'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.', // Design quality "Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.", "For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.", "Always include realistic, professional-looking sample data. For blogs include 3-4 posts with varied titles, authors, dates, categories. For products include names, prices, images. Never leave data empty.", ], }, ); /** * Type for the Svelte schema */ export type SvelteSchema = typeof schema; /** * Infer the spec type from a catalog */ export type SvelteSpec = typeof schema extends { createCatalog: (catalog: TCatalog) => { _specType: infer S }; } ? S : never; ================================================ FILE: packages/svelte/src/streaming.svelte.ts ================================================ import type { Spec, JsonPatch } from "@json-render/core"; import { setByPath, getByPath, removeByPath, createMixedStreamParser, applySpecPatch, } from "@json-render/core"; /** * Token usage metadata from AI generation */ export interface TokenUsage { promptTokens: number; completionTokens: number; totalTokens: number; } /** * UI Stream state */ export interface UIStreamState { spec: Spec | null; isStreaming: boolean; error: Error | null; usage: TokenUsage | null; rawLines: string[]; } /** * UI Stream return type */ export interface UIStreamReturn { readonly spec: Spec | null; readonly isStreaming: boolean; readonly error: Error | null; readonly usage: TokenUsage | null; readonly rawLines: string[]; send: (prompt: string, context?: Record) => Promise; clear: () => void; } /** * Options for createUIStream */ export interface UIStreamOptions { api: string; onComplete?: (spec: Spec) => void; onError?: (error: Error) => void; } type ParsedLine = | { type: "patch"; patch: JsonPatch } | { type: "usage"; usage: TokenUsage } | null; function parseLine(line: string): ParsedLine { try { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("//")) { return null; } const parsed = JSON.parse(trimmed); if (parsed.__meta === "usage") { return { type: "usage", usage: { promptTokens: parsed.promptTokens ?? 0, completionTokens: parsed.completionTokens ?? 0, totalTokens: parsed.totalTokens ?? 0, }, }; } return { type: "patch", patch: parsed as JsonPatch }; } catch { return null; } } function setSpecValue(newSpec: Spec, path: string, value: unknown): void { if (path === "/root") { newSpec.root = value as string; return; } if (path === "/state") { newSpec.state = value as Record; return; } if (path.startsWith("/state/")) { if (!newSpec.state) newSpec.state = {}; const statePath = path.slice("/state".length); setByPath(newSpec.state as Record, statePath, value); return; } if (path.startsWith("/elements/")) { const pathParts = path.slice("/elements/".length).split("/"); const elementKey = pathParts[0]; if (!elementKey) return; if (pathParts.length === 1) { newSpec.elements[elementKey] = value as Spec["elements"][string]; } else { const element = newSpec.elements[elementKey]; if (element) { const propPath = "/" + pathParts.slice(1).join("/"); const newElement = { ...element }; setByPath( newElement as unknown as Record, propPath, value, ); newSpec.elements[elementKey] = newElement; } } } } function removeSpecValue(newSpec: Spec, path: string): void { if (path === "/state") { delete newSpec.state; return; } if (path.startsWith("/state/") && newSpec.state) { const statePath = path.slice("/state".length); removeByPath(newSpec.state as Record, statePath); return; } if (path.startsWith("/elements/")) { const pathParts = path.slice("/elements/".length).split("/"); const elementKey = pathParts[0]; if (!elementKey) return; if (pathParts.length === 1) { const { [elementKey]: _, ...rest } = newSpec.elements; newSpec.elements = rest; } else { const element = newSpec.elements[elementKey]; if (element) { const propPath = "/" + pathParts.slice(1).join("/"); const newElement = { ...element }; removeByPath( newElement as unknown as Record, propPath, ); newSpec.elements[elementKey] = newElement; } } } } function getSpecValue(spec: Spec, path: string): unknown { if (path === "/root") return spec.root; if (path === "/state") return spec.state; if (path.startsWith("/state/") && spec.state) { const statePath = path.slice("/state".length); return getByPath(spec.state as Record, statePath); } return getByPath(spec as unknown as Record, path); } function applyPatch(spec: Spec, patch: JsonPatch): Spec { const newSpec = { ...spec, elements: { ...spec.elements }, ...(spec.state ? { state: { ...spec.state } } : {}), }; switch (patch.op) { case "add": case "replace": { setSpecValue(newSpec, patch.path, patch.value); break; } case "remove": { removeSpecValue(newSpec, patch.path); break; } case "move": { if (!patch.from) break; const moveValue = getSpecValue(newSpec, patch.from); removeSpecValue(newSpec, patch.from); setSpecValue(newSpec, patch.path, moveValue); break; } case "copy": { if (!patch.from) break; const copyValue = getSpecValue(newSpec, patch.from); setSpecValue(newSpec, patch.path, copyValue); break; } case "test": { break; } } return newSpec; } /** * Create a streaming UI generator using Svelte 5 $state */ export function createUIStream({ api, onComplete, onError, }: UIStreamOptions): UIStreamReturn { let spec = $state(null); let isStreaming = $state(false); let error = $state(null); let usage = $state(null); let rawLines = $state([]); let abortController: AbortController | null = null; const clear = () => { spec = null; error = null; usage = null; rawLines = []; }; const send = async ( prompt: string, context?: Record, ): Promise => { abortController?.abort(); abortController = new AbortController(); isStreaming = true; error = null; usage = null; rawLines = []; const previousSpec = context?.previousSpec as Spec | undefined; let currentSpec: Spec = previousSpec && previousSpec.root ? { ...previousSpec, elements: { ...previousSpec.elements } } : { root: "", elements: {} }; spec = currentSpec; try { const response = await fetch(api, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, context, currentSpec }), signal: abortController.signal, }); if (!response.ok) { let errorMessage = `HTTP error: ${response.status}`; try { const errorData = await response.json(); if (errorData.message) errorMessage = errorData.message; else if (errorData.error) errorMessage = errorData.error; } catch { // Ignore } throw new Error(errorMessage); } const reader = response.body?.getReader(); if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; const result = parseLine(trimmed); if (!result) continue; if (result.type === "usage") { usage = result.usage; } else { rawLines = [...rawLines, trimmed]; currentSpec = applyPatch(currentSpec, result.patch); spec = { ...currentSpec }; } } } if (buffer.trim()) { const trimmed = buffer.trim(); const result = parseLine(trimmed); if (result) { if (result.type === "usage") { usage = result.usage; } else { rawLines = [...rawLines, trimmed]; currentSpec = applyPatch(currentSpec, result.patch); spec = { ...currentSpec }; } } } onComplete?.(currentSpec); } catch (err) { if ((err as Error).name === "AbortError") return; const e = err instanceof Error ? err : new Error(String(err)); error = e; onError?.(e); } finally { isStreaming = false; } }; return { get spec() { return spec; }, get isStreaming() { return isStreaming; }, get error() { return error; }, get usage() { return usage; }, get rawLines() { return rawLines; }, send, clear, }; } /** * Chat message type */ export interface ChatMessage { id: string; role: "user" | "assistant"; text: string; spec: Spec | null; } /** * Chat UI options */ export interface ChatUIOptions { api: string; onComplete?: (message: ChatMessage) => void; onError?: (error: Error) => void; } /** * Chat UI return type */ export interface ChatUIReturn { readonly messages: ChatMessage[]; readonly isStreaming: boolean; readonly error: Error | null; send: (text: string) => Promise; clear: () => void; } let chatMessageIdCounter = 0; function generateChatId(): string { if ( typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ) { return crypto.randomUUID(); } chatMessageIdCounter += 1; return `msg-${Date.now()}-${chatMessageIdCounter}`; } /** * Create a chat UI with streaming support */ export function createChatUI({ api, onComplete, onError, }: ChatUIOptions): ChatUIReturn { let messages = $state([]); let isStreaming = $state(false); let error = $state(null); let abortController: AbortController | null = null; const clear = () => { messages = []; error = null; }; const send = async (text: string): Promise => { if (!text.trim()) return; abortController?.abort(); abortController = new AbortController(); const userMessage: ChatMessage = { id: generateChatId(), role: "user", text: text.trim(), spec: null, }; const assistantId = generateChatId(); const assistantMessage: ChatMessage = { id: assistantId, role: "assistant", text: "", spec: null, }; messages = [...messages, userMessage, assistantMessage]; isStreaming = true; error = null; const historyForApi = messages .slice(0, -1) .map((m) => ({ role: m.role, content: m.text })); let accumulatedText = ""; let currentSpec: Spec = { root: "", elements: {} }; let hasSpec = false; try { const response = await fetch(api, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: historyForApi }), signal: abortController.signal, }); if (!response.ok) { let errorMessage = `HTTP error: ${response.status}`; try { const errorData = await response.json(); if (errorData.message) errorMessage = errorData.message; else if (errorData.error) errorMessage = errorData.error; } catch { // Ignore } throw new Error(errorMessage); } const reader = response.body?.getReader(); if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); const parser = createMixedStreamParser({ onPatch(patch) { hasSpec = true; applySpecPatch(currentSpec, patch); messages = messages.map((m) => m.id === assistantId ? { ...m, spec: { root: currentSpec.root, elements: { ...currentSpec.elements }, ...(currentSpec.state ? { state: { ...currentSpec.state } } : {}), }, } : m, ); }, onText(line) { accumulatedText += (accumulatedText ? "\n" : "") + line; messages = messages.map((m) => m.id === assistantId ? { ...m, text: accumulatedText } : m, ); }, }); while (true) { const { done, value } = await reader.read(); if (done) break; parser.push(decoder.decode(value, { stream: true })); } parser.flush(); const finalMessage: ChatMessage = { id: assistantId, role: "assistant", text: accumulatedText, spec: hasSpec ? { root: currentSpec.root, elements: { ...currentSpec.elements }, ...(currentSpec.state ? { state: { ...currentSpec.state } } : {}), } : null, }; onComplete?.(finalMessage); } catch (err) { if ((err as Error).name === "AbortError") return; const e = err instanceof Error ? err : new Error(String(err)); error = e; messages = messages.filter( (m) => m.id !== assistantId || m.text.length > 0, ); onError?.(e); } finally { isStreaming = false; } }; return { get messages() { return messages; }, get isStreaming() { return isStreaming; }, get error() { return error; }, send, clear, }; } ================================================ FILE: packages/svelte/src/utils.svelte.ts ================================================ import type { Spec, UIElement, FlatElement, SpecDataPart, } from "@json-render/core"; import { applySpecPatch, nestedToFlat, SPEC_DATA_PART_TYPE, } from "@json-render/core"; /** * A single part from an AI response. Minimal structural type for library helpers. */ export interface DataPart { type: string; text?: string; data?: unknown; } /** * Convert a flat element list to a Spec. * Input elements use key/parentKey to establish identity and relationships. * Output spec uses the map-based format where key is the map entry key * and parent-child relationships are expressed through children arrays. */ export function flatToTree(elements: FlatElement[]): Spec { const elementMap: Record = {}; let root = ""; // First pass: add all elements to map for (const element of elements) { elementMap[element.key] = { type: element.type, props: element.props, children: [], visible: element.visible, }; } // Second pass: build parent-child relationships for (const element of elements) { if (element.parentKey) { const parent = elementMap[element.parentKey]; if (parent) { if (!parent.children) { parent.children = []; } parent.children.push(element.key); } } else { root = element.key; } } return { root, elements: elementMap }; } /** * Type guard that validates a data part payload looks like a valid SpecDataPart. */ function isSpecDataPart(data: unknown): data is SpecDataPart { if (typeof data !== "object" || data === null) return false; const obj = data as Record; switch (obj.type) { case "patch": return typeof obj.patch === "object" && obj.patch !== null; case "flat": case "nested": return typeof obj.spec === "object" && obj.spec !== null; default: return false; } } /** * Build a `Spec` by replaying all spec data parts from a message's parts array. * Returns `null` if no spec data parts are present. */ export function buildSpecFromParts( parts: DataPart[], snapshot = true, ): Spec | null { const spec: Spec = { root: "", elements: {} }; let hasSpec = false; for (const part of parts) { if (part.type === SPEC_DATA_PART_TYPE) { if (!isSpecDataPart(part.data)) continue; const payload = part.data; if (payload.type === "patch") { hasSpec = true; applySpecPatch( spec, snapshot ? $state.snapshot(payload.patch) : payload.patch, ); } else if (payload.type === "flat") { hasSpec = true; Object.assign(spec, payload.spec); } else if (payload.type === "nested") { hasSpec = true; const flat = nestedToFlat(payload.spec); Object.assign(spec, flat); } } } return hasSpec ? spec : null; } /** * Extract and join all text content from a message's parts array. */ export function getTextFromParts(parts: DataPart[]): string { return parts .filter( (p): p is DataPart & { text: string } => p.type === "text" && typeof p.text === "string", ) .map((p) => p.text.trim()) .filter(Boolean) .join("\n\n"); } ================================================ FILE: packages/svelte/src/utils.test.ts ================================================ import { describe, it, expect } from "vitest"; import { flatToTree, buildSpecFromParts, getTextFromParts, } from "./utils.svelte.js"; import type { FlatElement, SpecDataPart } from "@json-render/core"; import { SPEC_DATA_PART_TYPE } from "@json-render/core"; describe("flatToTree", () => { it("converts array of elements to tree structure", () => { const elements: FlatElement[] = [ { key: "root", type: "Container", props: {}, parentKey: undefined }, { key: "child1", type: "Text", props: { text: "Hello" }, parentKey: "root", }, ]; const spec = flatToTree(elements); expect(spec.root).toBe("root"); expect(spec.elements["root"]).toBeDefined(); expect(spec.elements["child1"]).toBeDefined(); }); it("builds parent-child relationships", () => { const elements: FlatElement[] = [ { key: "root", type: "Container", props: {}, parentKey: undefined }, { key: "child1", type: "Text", props: {}, parentKey: "root" }, { key: "child2", type: "Text", props: {}, parentKey: "root" }, ]; const spec = flatToTree(elements); expect(spec.elements["root"]?.children).toEqual(["child1", "child2"]); }); it("handles single root element", () => { const elements: FlatElement[] = [ { key: "only", type: "Text", props: { text: "Solo" }, parentKey: undefined, }, ]; const spec = flatToTree(elements); expect(spec.root).toBe("only"); expect(spec.elements["only"]?.children).toEqual([]); }); it("handles deeply nested elements", () => { const elements: FlatElement[] = [ { key: "root", type: "Container", props: {}, parentKey: undefined }, { key: "level1", type: "Container", props: {}, parentKey: "root" }, { key: "level2", type: "Container", props: {}, parentKey: "level1" }, { key: "level3", type: "Text", props: {}, parentKey: "level2" }, ]; const spec = flatToTree(elements); expect(spec.elements["root"]?.children).toEqual(["level1"]); expect(spec.elements["level1"]?.children).toEqual(["level2"]); expect(spec.elements["level2"]?.children).toEqual(["level3"]); expect(spec.elements["level3"]?.children).toEqual([]); }); it("preserves element props", () => { const elements: FlatElement[] = [ { key: "root", type: "Card", props: { title: "Hello", value: 42 }, parentKey: undefined, }, ]; const spec = flatToTree(elements); expect(spec.elements["root"]?.props).toEqual({ title: "Hello", value: 42 }); }); it("preserves visibility conditions", () => { const elements: FlatElement[] = [ { key: "root", type: "Container", props: {}, parentKey: undefined, visible: { $state: "/isVisible" }, }, ]; const spec = flatToTree(elements); expect(spec.elements["root"]?.visible).toEqual({ $state: "/isVisible" }); }); it("handles elements with undefined parentKey as root", () => { const elements: FlatElement[] = [ { key: "a", type: "Text", props: {}, parentKey: undefined }, ]; const spec = flatToTree(elements); expect(spec.root).toBe("a"); }); it("handles empty elements array", () => { const spec = flatToTree([]); expect(spec.root).toBe(""); expect(spec.elements).toEqual({}); }); it("handles multiple children correctly", () => { const elements: FlatElement[] = [ { key: "root", type: "Container", props: {}, parentKey: undefined }, { key: "a", type: "Text", props: {}, parentKey: "root" }, { key: "b", type: "Text", props: {}, parentKey: "root" }, { key: "c", type: "Text", props: {}, parentKey: "root" }, ]; const spec = flatToTree(elements); expect(spec.elements["root"]?.children).toHaveLength(3); expect(spec.elements["root"]?.children).toContain("a"); expect(spec.elements["root"]?.children).toContain("b"); expect(spec.elements["root"]?.children).toContain("c"); }); }); describe("buildSpecFromParts", () => { it("returns null when no data-spec parts are present", () => { const parts = [ { type: "text", text: "Hello world" }, { type: "other", data: {} }, ]; const spec = buildSpecFromParts(parts); expect(spec).toBeNull(); }); it("builds a spec from patch parts", () => { const parts = [ { type: SPEC_DATA_PART_TYPE, data: { type: "patch", patch: { op: "add", path: "/root", value: "main" }, } satisfies SpecDataPart, }, { type: SPEC_DATA_PART_TYPE, data: { type: "patch", patch: { op: "add", path: "/elements/main", value: { type: "Text", props: { text: "Hi" }, children: [] }, }, } satisfies SpecDataPart, }, ]; const spec = buildSpecFromParts(parts); expect(spec).not.toBeNull(); expect(spec?.root).toBe("main"); expect(spec?.elements["main"]?.type).toBe("Text"); }); it("handles flat spec parts", () => { const parts = [ { type: SPEC_DATA_PART_TYPE, data: { type: "flat", spec: { root: "root", elements: { root: { type: "Container", props: {}, children: [] }, }, }, } satisfies SpecDataPart, }, ]; const spec = buildSpecFromParts(parts); expect(spec?.root).toBe("root"); expect(spec?.elements["root"]?.type).toBe("Container"); }); it("ignores non-spec parts", () => { const parts = [ { type: "text", text: "Some text" }, { type: SPEC_DATA_PART_TYPE, data: { type: "flat", spec: { root: "r", elements: { r: { type: "Text", props: {}, children: [] } }, }, } satisfies SpecDataPart, }, { type: "tool-call", data: {} }, ]; const spec = buildSpecFromParts(parts); expect(spec?.root).toBe("r"); }); it("applies patches incrementally", () => { const parts = [ { type: SPEC_DATA_PART_TYPE, data: { type: "patch", patch: { op: "add", path: "/root", value: "a" }, } satisfies SpecDataPart, }, { type: SPEC_DATA_PART_TYPE, data: { type: "patch", patch: { op: "add", path: "/elements/a", value: { type: "Text", props: { n: 1 }, children: [] }, }, } satisfies SpecDataPart, }, { type: SPEC_DATA_PART_TYPE, data: { type: "patch", patch: { op: "replace", path: "/elements/a/props/n", value: 2 }, } satisfies SpecDataPart, }, ]; const spec = buildSpecFromParts(parts); expect((spec?.elements["a"]?.props as { n: number }).n).toBe(2); }); it("handles nested spec parts via nestedToFlat", () => { const parts = [ { type: SPEC_DATA_PART_TYPE, data: { type: "nested", spec: { type: "Container", props: {}, children: [{ type: "Text", props: { t: "x" } }], }, } satisfies SpecDataPart, }, ]; const spec = buildSpecFromParts(parts); expect(spec).not.toBeNull(); expect(Object.keys(spec?.elements ?? {}).length).toBeGreaterThan(0); }); it("handles mixed patch + flat + nested parts in sequence", () => { const parts = [ { type: SPEC_DATA_PART_TYPE, data: { type: "patch", patch: { op: "add", path: "/root", value: "initial" }, } satisfies SpecDataPart, }, { type: SPEC_DATA_PART_TYPE, data: { type: "flat", spec: { root: "replaced", elements: { replaced: { type: "Box", props: {}, children: [] } }, }, } satisfies SpecDataPart, }, ]; const spec = buildSpecFromParts(parts); expect(spec?.root).toBe("replaced"); }); it("returns empty elements map from empty parts list", () => { const spec = buildSpecFromParts([]); expect(spec).toBeNull(); }); }); describe("getTextFromParts", () => { it("extracts text from text parts", () => { const parts = [ { type: "text", text: "Hello" }, { type: "text", text: "World" }, ]; const text = getTextFromParts(parts); expect(text).toBe("Hello\n\nWorld"); }); it("returns empty string when no text parts", () => { const parts = [ { type: "data", data: {} }, { type: "tool-call", data: {} }, ]; const text = getTextFromParts(parts); expect(text).toBe(""); }); it("ignores non-text parts", () => { const parts = [ { type: "text", text: "Keep" }, { type: "data", data: {} }, { type: "text", text: "This" }, ]; const text = getTextFromParts(parts); expect(text).toBe("Keep\n\nThis"); }); it("trims whitespace from text parts", () => { const parts = [ { type: "text", text: " Hello " }, { type: "text", text: "\n\nWorld\n\n" }, ]; const text = getTextFromParts(parts); expect(text).toBe("Hello\n\nWorld"); }); it("skips empty text parts", () => { const parts = [ { type: "text", text: "Hello" }, { type: "text", text: " " }, { type: "text", text: "World" }, ]; const text = getTextFromParts(parts); expect(text).toBe("Hello\n\nWorld"); }); it("ignores text parts with non-string text field", () => { const parts = [ { type: "text", text: "Valid" }, { type: "text", text: 123 as unknown as string }, { type: "text", text: "Also Valid" }, ]; const text = getTextFromParts(parts); expect(text).toBe("Valid\n\nAlso Valid"); }); }); ================================================ FILE: packages/svelte/svelte.config.js ================================================ /** @type {import('@sveltejs/package').Config} */ export default { compilerOptions: { runes: true, // ensure no legacy syntax sneaks into this code base }, }; ================================================ FILE: packages/svelte/tsconfig.json ================================================ { "extends": "@internal/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "types": ["svelte"], "verbatimModuleSyntax": true }, "include": ["src"], "exclude": ["node_modules", "dist", "./**/*.test.ts"] } ================================================ FILE: packages/typescript-config/base.json ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "declaration": true, "declarationMap": true, "esModuleInterop": true, "incremental": false, "isolatedModules": true, "lib": ["es2022", "DOM", "DOM.Iterable"], "module": "NodeNext", "moduleDetection": "force", "moduleResolution": "NodeNext", "noUncheckedIndexedAccess": true, "resolveJsonModule": true, "skipLibCheck": true, "strict": true, "target": "ES2022" } } ================================================ FILE: packages/typescript-config/nextjs.json ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "./base.json", "compilerOptions": { "plugins": [{ "name": "next" }], "module": "ESNext", "moduleResolution": "Bundler", "allowJs": true, "jsx": "preserve", "noEmit": true } } ================================================ FILE: packages/typescript-config/package.json ================================================ { "name": "@internal/typescript-config", "version": "0.0.0", "private": true, "license": "Apache-2.0", "publishConfig": { "access": "public" } } ================================================ FILE: packages/typescript-config/react-library.json ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "./base.json", "compilerOptions": { "jsx": "react-jsx" } } ================================================ FILE: packages/ui/eslint.config.mjs ================================================ import { config } from "@internal/eslint-config/react-internal"; /** @type {import("eslint").Linter.Config} */ export default config; ================================================ FILE: packages/ui/package.json ================================================ { "name": "@internal/ui", "version": "0.0.0", "private": true, "license": "Apache-2.0", "exports": { "./*": "./src/*.tsx" }, "scripts": { "lint": "eslint . --max-warnings 0", "generate:component": "turbo gen react-component", "check-types": "tsc --noEmit" }, "devDependencies": { "@internal/eslint-config": "workspace:*", "@internal/typescript-config": "workspace:*", "@types/node": "^22.15.3", "@types/react": "19.2.3", "@types/react-dom": "19.2.3", "eslint": "^9.39.1", "typescript": "5.9.2" }, "dependencies": { "react": "19.2.3", "react-dom": "19.2.3" } } ================================================ FILE: packages/ui/src/button.tsx ================================================ "use client"; import { ReactNode } from "react"; interface ButtonProps { children: ReactNode; className?: string; appName: string; } export const Button = ({ children, className, appName }: ButtonProps) => { return ( ); }; ================================================ FILE: packages/ui/src/card.tsx ================================================ import { type JSX } from "react"; export function Card({ className, title, children, href, }: { className?: string; title: string; children: React.ReactNode; href: string; }): JSX.Element { return (

{title} ->

{children}

); } ================================================ FILE: packages/ui/src/code.tsx ================================================ import { type JSX } from "react"; export function Code({ children, className, }: { children: React.ReactNode; className?: string; }): JSX.Element { return {children}; } ================================================ FILE: packages/ui/tsconfig.json ================================================ { "extends": "@internal/typescript-config/react-library.json", "compilerOptions": { "outDir": "dist" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/vue/CHANGELOG.md ================================================ # @json-render/vue ## 0.14.1 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 ## 0.14.0 ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 ## 0.13.0 ### Patch Changes - Updated dependencies [5b32de8] - @json-render/core@0.13.0 ## 0.12.1 ### Patch Changes - Updated dependencies [54a1ecf] - @json-render/core@0.12.1 ## 0.12.0 ### Patch Changes - Updated dependencies [63c339b] - @json-render/core@0.12.0 ## 0.11.0 ### Patch Changes - Updated dependencies [3f1e71e] - @json-render/core@0.11.0 ## 0.10.0 ### Minor Changes - 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values. ### New: `@json-render/vue` Package Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming. - `defineRegistry` — create type-safe component registries from catalogs - `Renderer` — render specs as Vue component trees - Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider` - Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation` - Streaming: `useUIStream`, `useChatUI` - External store support via `StateStore` interface ### New: `@json-render/xstate` Package XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend. - `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom - Requires `@xstate/store` v3+ ### New: `$computed` Expressions Call registered functions from prop expressions: - `{ "$computed": "functionName", "args": { "key": } }` — calls a named function with resolved args - Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer` - `ComputedFunction` type exported from `@json-render/core` ### New: `$template` Expressions Interpolate state values into strings: - `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values - Missing paths resolve to empty string ### New: State Watchers React to state changes by triggering actions: - `watch` field on elements maps state paths to action bindings - Fires when watched values change (not on initial render) - Supports cascading dependencies (e.g. country → city loading) - `watch` is a top-level field on elements (sibling of type/props/children), not inside props - Spec validator detects and auto-fixes `watch` placed inside props ### New: Cross-Field Validation Functions New built-in validation functions for cross-field comparisons: - `equalTo` — alias for `matches` with clearer semantics - `lessThan` — value must be less than another field (numbers, strings, coerced) - `greaterThan` — value must be greater than another field - `requiredIf` — required only when a condition field is truthy - Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling ### New: `validateForm` Action (React) Built-in action that validates all registered form fields at once: - Runs `validateAll()` synchronously and writes `{ valid, errors }` to state - Default state path: `/formValidation` (configurable via `statePath` param) - Added to React schema's built-in actions list ### Improved: shadcn/ui Validation All form components now support validation: - Checkbox, Radio, Switch — added `checks` and `validateOn` props - Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit) - Shared validation schemas reduce catalog definition duplication ### Improved: React Provider Tree Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access. ### Patch Changes - Updated dependencies [9cef4e9] - @json-render/core@0.10.0 ================================================ FILE: packages/vue/README.md ================================================ # @json-render/vue Vue 3 renderer for json-render. Turn JSON specs into Vue components with data binding, visibility, and actions. ## Installation ```bash npm install @json-render/vue @json-render/core zod ``` Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`. ## Quick Start ### 1. Create a Catalog ```typescript import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/vue/schema"; import { z } from "zod"; export const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string(), description: z.string().nullable(), }), description: "A card container", }, Button: { props: z.object({ label: z.string(), action: z.string(), }), description: "A clickable button", }, Input: { props: z.object({ value: z.union([z.string(), z.record(z.unknown())]).nullable(), label: z.string(), placeholder: z.string().nullable(), }), description: "Text input field with optional value binding", }, }, actions: { submit: { description: "Submit the form" }, cancel: { description: "Cancel and close" }, }, }); ``` ### 2. Define Component Implementations Components are written using Vue's `h()` render function. `children` is a `VNode | VNode[]` — pass it directly to your container element. `defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it entirely. ```typescript import { h } from "vue"; import { defineRegistry } from "@json-render/vue"; import { catalog } from "./catalog"; export const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => h("div", { class: "card" }, [ h("h3", null, props.title), props.description ? h("p", null, props.description) : null, children, ]), Button: ({ props, emit }) => h("button", { onClick: () => emit("press") }, props.label), Input: ({ props, bindings }) => { // Use bindings?.value with a watcher to implement two-way binding return h("label", null, [ props.label, h("input", { placeholder: props.placeholder ?? "", value: props.value ?? "", }), ]); }, }, // actions stubs are required when the catalog declares actions: actions: { submit: async () => {}, cancel: async () => {}, }, }); ``` > **Tip:** Use `useBoundProp(props.value, bindings?.value)` for two-way binding, or handle the `bindings` object directly in your component. ### 3. Render Specs ```vue ``` ## Spec Format The Vue renderer uses the same flat element map format as the React renderer: ```typescript interface Spec { root: string; // Key of the root element elements: Record; // Flat map of elements by key state?: Record; // Optional initial state } interface UIElement { type: string; // Component name from catalog props: Record; // Component props children?: string[]; // Keys of child elements visible?: VisibilityCondition; // Visibility condition } ``` Example spec: ```json { "root": "card-1", "elements": { "card-1": { "type": "Card", "props": { "title": "Welcome" }, "children": ["input-1", "btn-1"] }, "input-1": { "type": "Input", "props": { "value": { "$bindState": "/form/name" }, "label": "Name", "placeholder": "Enter name" } }, "btn-1": { "type": "Button", "props": { "label": "Submit" }, "children": [] } } } ``` ## Providers ### StateProvider Share data across components with JSON Pointer paths: ```vue ``` ```typescript // In composables: const { state, get, set } = useStateStore(); const name = get("/user/name"); // "John" set("/user/age", 25); // state is a ShallowRef — access with state.value console.log(state.value); ``` #### External Store (Controlled Mode) Pass a `StateStore` to bypass the internal state and wire json-render to any state management library (Pinia, VueUse, etc.): ```typescript import { createStateStore, type StateStore } from "@json-render/vue"; // Option 1: Use the built-in store outside of Vue const store = createStateStore({ count: 0 }); ``` ```vue ``` ```typescript // Mutate from anywhere — Vue will re-render automatically: store.set("/count", 1); // Option 2: Implement the StateStore interface with your own backend const piniaStore: StateStore = { get: (path) => getByPath(myStore.$state, path), set: (path, value) => myStore.$patch(/* ... */), update: (updates) => myStore.$patch(/* ... */), getSnapshot: () => myStore.$state, subscribe: (listener) => myStore.$subscribe(listener), }; ``` When `store` is provided, `initialState` and `onStateChange` are ignored. ### ActionProvider Handle actions from components: ```vue ``` ### VisibilityProvider Control element visibility based on data: ```vue ``` ```json { "type": "Alert", "props": { "message": "Error!" }, "visible": { "$state": "/form/hasError" } } ``` ### ValidationProvider Add field validation: ```vue ``` ```typescript // Use validation composable: const { errors, validate } = useFieldValidation("/form/email", { checks: [ { type: "required", message: "Email required" }, { type: "email", message: "Invalid email" }, ], }); ``` ## Composables | Composable | Purpose | |------------|---------| | `useStateStore()` | Access state context (`state` as `ShallowRef`, `get`, `set`, `update`) | | `useStateValue(path)` | Get single value from state | | `useStateBinding(path)` | Two-way data binding (deprecated — use `$bindState` instead) | | `useIsVisible(condition)` | Check if a visibility condition is met | | `useActions()` | Access action context | | `useAction(binding)` | Get a single action dispatch function | | `useFieldValidation(path, config)` | Field validation state | > **Note:** `useStateStore().state` returns a `ShallowRef` — use `state.value` to access the underlying object. This differs from the React renderer where `state` is a plain object. ## Visibility Conditions ```typescript // Truthiness check { "$state": "/user/isAdmin" } // Auth state (use state path) { "$state": "/auth/isSignedIn" } // Comparisons (flat style) { "$state": "/status", "eq": "active" } { "$state": "/count", "gt": 10 } // Negation { "$state": "/maintenance", "not": true } // Multiple conditions (implicit AND) [ { "$state": "/feature/enabled" }, { "$state": "/maintenance", "not": true } ] // Always / never true // always visible false // never visible ``` TypeScript helpers from `@json-render/core`: ```typescript import { visibility } from "@json-render/core"; visibility.when("/path") // { $state: "/path" } visibility.unless("/path") // { $state: "/path", not: true } visibility.eq("/path", val) // { $state: "/path", eq: val } visibility.neq("/path", val) // { $state: "/path", neq: val } visibility.and(cond1, cond2) // { $and: [cond1, cond2] } visibility.always // true visibility.never // false ``` ## Dynamic Prop Expressions Any prop value can use data-driven expressions that resolve at render time. The renderer resolves these transparently before passing props to components. ```json { "type": "Badge", "props": { "label": { "$state": "/user/role" }, "color": { "$cond": { "$state": "/user/role", "eq": "admin" }, "$then": "red", "$else": "gray" } } } ``` For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop. Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop. See [@json-render/core](../core/README.md) for full expression syntax. ## Built-in Actions The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the Vue schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions: ```json { "type": "Button", "props": { "label": "Switch Tab" }, "on": { "press": { "action": "setState", "params": { "statePath": "/activeTab", "value": "settings" } } }, "children": [] } ``` ## Component Props When using `defineRegistry`, components receive these props via their render function: ```typescript import type { VNode } from "vue"; interface ComponentContext

{ props: P; // Typed props from the catalog (expressions resolved) children?: VNode | VNode[]; // Rendered children (for container components) emit: (event: string) => void; // Emit a named event (always defined) on: (event: string) => EventHandle; // Get event handle with metadata loading?: boolean; // Whether the parent is loading bindings?: Record; // State paths for $bindState/$bindItem expressions } interface EventHandle { emit: () => void; // Fire the event shouldPreventDefault: boolean; // Whether any binding requested preventDefault bound: boolean; // Whether any handler is bound } ``` Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault` or `bound`: ```typescript Link: ({ props, on }) => { const click = on("click"); return h("a", { href: props.href, onClick: (e: MouseEvent) => { if (click.shouldPreventDefault) e.preventDefault(); click.emit(); }, }, props.label); }, ``` ### `BaseComponentProps` For building reusable component libraries that are not tied to a specific catalog, use the catalog-agnostic `BaseComponentProps` type: ```typescript import type { BaseComponentProps } from "@json-render/vue"; const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => h("div", null, [props.title, children]); ``` ## Generate AI Prompts ```typescript const systemPrompt = catalog.prompt(); // Returns detailed prompt with component/action descriptions ``` ## Full Example ```typescript import { h } from "vue"; import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/vue/schema"; import { defineRegistry, Renderer, StateProvider } from "@json-render/vue"; import { z } from "zod"; const catalog = defineCatalog(schema, { components: { Greeting: { props: z.object({ name: z.string() }), description: "Displays a greeting", }, }, actions: {}, }); const { registry } = defineRegistry(catalog, { components: { Greeting: ({ props }) => h("h1", null, `Hello, ${props.name}!`), }, }); const spec = { root: "greeting-1", elements: { "greeting-1": { type: "Greeting", props: { name: "World" }, children: [], }, }, }; // In your App.vue: // // // ``` ## Key Exports | Export | Purpose | |--------|---------| | `defineRegistry` | Create a type-safe component registry from a catalog | | `Renderer` | Render a spec using a registry | | `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`) | | `useStateStore` | Access state context (`state` is `ShallowRef`) | | `useStateValue` | Get single value from state | | `useActions` | Access actions context | | `useAction` | Get a single action dispatch function | | `createStateStore` | Create a framework-agnostic in-memory `StateStore` | ### Types | Export | Purpose | |--------|---------| | `ComponentContext` | Typed component render function context (catalog-aware) | | `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries | | `EventHandle` | Event handle with `emit()`, `shouldPreventDefault`, `bound` | | `ActionProviderProps` | Props for `ActionProvider` | | `ValidationProviderProps` | Props for `ValidationProvider` | | `ComponentFn` | Component render function type | | `SetState` | State setter type | | `StateModel` | State model type | | `StateStore` | Interface for plugging in external state management | ## Differences from `@json-render/react` | API | React | Vue | Note | |-----|-------|-----|------| | `useStateStore().state` | `StateModel` | `ShallowRef` | Vue reactivity; use `state.value` | | `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific | | `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` | | Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values | ================================================ FILE: packages/vue/package.json ================================================ { "name": "@json-render/vue", "version": "0.14.1", "license": "Apache-2.0", "description": "Vue renderer for @json-render/core. JSON becomes Vue components.", "keywords": [ "json", "ui", "vue", "ai", "generative-ui", "llm", "renderer", "streaming", "components" ], "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", "directory": "packages/vue" }, "homepage": "https://json-render.dev", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "publishConfig": { "access": "public" }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" }, "./schema": { "types": "./dist/schema.d.ts", "import": "./dist/schema.mjs", "require": "./dist/schema.js" } }, "files": [ "dist" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit" }, "dependencies": { "@json-render/core": "workspace:*" }, "devDependencies": { "@internal/typescript-config": "workspace:*", "@vue/test-utils": "^2.4.6", "tsup": "^8.0.2", "typescript": "^5.4.5", "vue": "^3.5.0" }, "peerDependencies": { "vue": "^3.5.0", "zod": "^4.0.0" } } ================================================ FILE: packages/vue/src/catalog-types.ts ================================================ import type { VNode } from "vue"; import type { Catalog, InferCatalogComponents, InferCatalogActions, InferComponentProps, InferActionParams, StateModel, } from "@json-render/core"; export type { StateModel }; // ============================================================================= // State Types // ============================================================================= /** * State setter function for updating application state */ export type SetState = ( updater: (prev: Record) => Record, ) => void; // ============================================================================= // Component Types // ============================================================================= /** * Handle returned by the `on()` function for a specific event. * Provides metadata about the event binding and a method to fire it. * * @example * ```ts * const press = on("press"); * if (press.shouldPreventDefault) e.preventDefault(); * press.emit(); * ``` */ export interface EventHandle { /** Fire the event (resolve action bindings) */ emit: () => void; /** Whether any binding requested preventDefault */ shouldPreventDefault: boolean; /** Whether any handler is bound to this event */ bound: boolean; } /** * Catalog-agnostic base type for component render function arguments. * Use this when building reusable component libraries that are not tied to a specific catalog. * * @example * ```ts * const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => * h('div', null, [props.title, children]) * ``` */ export interface BaseComponentProps

> { props: P; /** Rendered children (from the default slot) */ children?: VNode | VNode[]; /** Simple event emitter (shorthand). Fires the event and returns void. */ emit: (event: string) => void; /** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */ on: (event: string) => EventHandle; /** * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions. * Maps prop name → absolute state path for write-back. */ bindings?: Record; loading?: boolean; } /** * Context passed to component render functions * @example * const Button: ComponentFn = (ctx) => { * return h('button', { onClick: () => ctx.emit("press") }, ctx.props.label) * } */ export interface ComponentContext< C extends Catalog, K extends keyof InferCatalogComponents, > extends BaseComponentProps> {} /** * Component render function type for Vue * @example * const Button: ComponentFn = ({ props, emit }) => * h('button', { onClick: () => emit("press") }, props.label) */ export type ComponentFn< C extends Catalog, K extends keyof InferCatalogComponents, > = (ctx: ComponentContext) => VNode | VNode[] | null | string; /** * Registry of all component render functions for a catalog * @example * const components: Components = { * Button: ({ props }) => h('button', null, props.label), * Input: ({ props }) => h('input', { placeholder: props.placeholder }), * }; */ export type Components = { [K in keyof InferCatalogComponents]: ComponentFn; }; // ============================================================================= // Action Types // ============================================================================= /** * Action handler function type * @example * const viewCustomers: ActionFn = async (params, setState) => { * const data = await fetch('/api/customers'); * setState(prev => ({ ...prev, customers: data })); * }; */ export type ActionFn< C extends Catalog, K extends keyof InferCatalogActions, > = ( params: InferActionParams | undefined, setState: SetState, state: StateModel, ) => Promise; /** * Registry of all action handlers for a catalog * @example * const actions: Actions = { * viewCustomers: async (params, setState) => { ... }, * createCustomer: async (params, setState) => { ... }, * }; */ export type Actions = { [K in keyof InferCatalogActions]: ActionFn; }; /** * True when the catalog declares at least one action, false otherwise. * Used by defineRegistry to conditionally require the `actions` field. */ export type CatalogHasActions = [ InferCatalogActions, ] extends [never] ? false : [keyof InferCatalogActions] extends [never] ? false : true; ================================================ FILE: packages/vue/src/composables/actions.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { defineComponent, h, type Component } from "vue"; import { mount } from "@vue/test-utils"; import { StateProvider, useStateStore } from "./state"; import { ActionProvider, useActions, useAction } from "./actions"; /** Mount StateProvider → ActionProvider with a child that captures context. */ function withProviders( composable: () => T, handlers: Record< string, (params: Record) => Promise > = {}, initialState: Record = {}, ): { result: T } { let result!: T; const Child = defineComponent({ setup() { result = composable(); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState } as any, slots: { default: () => h(ActionProvider as Component, { handlers } as any, { default: () => h(Child), }), }, }); return { result }; } describe("ActionProvider — provide/inject", () => { it("useActions() throws outside a provider", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(() => useActions()).toThrow( "useActions must be used within an ActionProvider", ); warn.mockRestore(); }); }); describe("ActionProvider — custom handler dispatch", () => { it("calling execute invokes the registered handler", async () => { const handler = vi.fn().mockResolvedValue(undefined); const { result } = withProviders(() => useActions(), { myAction: handler }); await result.execute({ action: "myAction", params: { x: 1 } }); expect(handler).toHaveBeenCalledOnce(); }); it("handler receives the resolved params", async () => { const handler = vi.fn().mockResolvedValue(undefined); const { result } = withProviders(() => useActions(), { myAction: handler }); await result.execute({ action: "myAction", params: { x: 1, y: "hello" } }); expect(handler).toHaveBeenCalledWith({ x: 1, y: "hello" }); }); it("console.warn is called for unknown actions", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const { result } = withProviders(() => useActions()); await result.execute({ action: "unknownAction" }); expect(warn).toHaveBeenCalledWith(expect.stringContaining("unknownAction")); warn.mockRestore(); }); }); describe("ActionProvider — built-in setState integration", () => { it("executing setState updates state via the provider chain", async () => { let stateCtx!: ReturnType; let actionsCtx!: ReturnType; const Child = defineComponent({ setup() { stateCtx = useStateStore(); actionsCtx = useActions(); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState: { v: 0 } } as any, slots: { default: () => h(ActionProvider as Component, null, { default: () => h(Child), }), }, }); await actionsCtx.execute({ action: "setState", params: { statePath: "/v", value: 42 }, }); expect(stateCtx.state.value).toEqual({ v: 42 }); }); }); describe("useAction", () => { it("returns { execute, isLoading: false } before execution", () => { const { result } = withProviders(() => useAction({ action: "myAction" })); expect(typeof result.execute).toBe("function"); expect(result.isLoading.value).toBe(false); }); }); ================================================ FILE: packages/vue/src/composables/actions.ts ================================================ import { computed, defineComponent, h, inject, provide, ref, watch, type ComputedRef, type PropType, } from "vue"; import { resolveAction, executeAction, type ActionBinding, type ActionHandler, type ActionConfirm, type ResolvedAction, } from "@json-render/core"; import { useStateStore } from "./state"; import { useOptionalValidation } from "./validation"; /** * Generate a unique ID for use with the "$id" token. */ let idCounter = 0; function generateUniqueId(): string { idCounter += 1; return `${Date.now()}-${idCounter}`; } /** * Deep-resolve dynamic value references within an object. * * Supported tokens: * - `{ $state: "/statePath" }` - read a value from state * - `"$id"` (string) or `{ "$id": true }` - generate a unique ID */ function deepResolveValue( value: unknown, get: (path: string) => unknown, ): unknown { if (value === null || value === undefined) return value; if (value === "$id") { return generateUniqueId(); } if (typeof value === "object" && !Array.isArray(value)) { const obj = value as Record; const keys = Object.keys(obj); if (keys.length === 1 && typeof obj.$state === "string") { return get(obj.$state as string); } if (keys.length === 1 && "$id" in obj) { return generateUniqueId(); } } if (Array.isArray(value)) { return value.map((item) => deepResolveValue(item, get)); } if (typeof value === "object") { const resolved: Record = {}; for (const [key, val] of Object.entries(value as Record)) { resolved[key] = deepResolveValue(val, get); } return resolved; } return value; } /** * Pending confirmation state */ export interface PendingConfirmation { action: ResolvedAction; handler: ActionHandler; resolve: () => void; reject: () => void; } /** * Action context value */ export interface ActionContextValue { handlers: Record; loadingActions: Set; pendingConfirmation: PendingConfirmation | null; execute: (binding: ActionBinding) => Promise; confirm: () => void; cancel: () => void; registerHandler: (name: string, handler: ActionHandler) => void; } const ACTIONS_KEY = Symbol("json-render:actions"); export interface ActionProviderProps { handlers?: Record; navigate?: (path: string) => void; } /** * Provider for action execution */ export const ActionProvider = defineComponent({ name: "ActionProvider", props: { handlers: { type: Object as PropType>, default: () => ({}), }, navigate: { type: Function as PropType<(path: string) => void>, default: undefined, }, }, setup(props, { slots }) { const { get, set, getSnapshot } = useStateStore(); const validation = useOptionalValidation(); const handlers = ref>(props.handlers ?? {}); const loadingActions = ref>(new Set()); const pendingConfirmation = ref(null); // Sync handlers when prop changes watch( () => props.handlers, (newHandlers) => { if (newHandlers) handlers.value = newHandlers; }, ); const registerHandler = (name: string, handler: ActionHandler) => { handlers.value = { ...handlers.value, [name]: handler }; }; const execute = async (binding: ActionBinding): Promise => { const resolved = resolveAction(binding, getSnapshot()); // Built-in: setState if (resolved.action === "setState" && resolved.params) { const statePath = resolved.params.statePath as string; const value = resolved.params.value; if (statePath) { set(statePath, value); } return; } // Built-in: pushState if (resolved.action === "pushState" && resolved.params) { const statePath = resolved.params.statePath as string; const rawValue = resolved.params.value; if (statePath) { const resolvedValue = deepResolveValue(rawValue, get); const arr = (get(statePath) as unknown[] | undefined) ?? []; set(statePath, [...arr, resolvedValue]); const clearStatePath = resolved.params.clearStatePath as | string | undefined; if (clearStatePath) { set(clearStatePath, ""); } } return; } // Built-in: removeState if (resolved.action === "removeState" && resolved.params) { const statePath = resolved.params.statePath as string; const index = resolved.params.index as number; if (statePath !== undefined && index !== undefined) { const arr = (get(statePath) as unknown[] | undefined) ?? []; set( statePath, arr.filter((_, i) => i !== index), ); } return; } // Built-in: validateForm — triggers validateAll and writes result to state if (resolved.action === "validateForm") { const validateAll = validation?.validateAll; if (!validateAll) { console.warn( "validateForm action was dispatched but no ValidationProvider is connected. " + "Ensure ValidationProvider is rendered inside the provider tree.", ); return; } const valid = validateAll(); const errors: Record = {}; for (const [path, fs] of Object.entries(validation.fieldStates)) { if (fs.result && !fs.result.valid) { errors[path] = fs.result.errors; } } const statePath = (resolved.params?.statePath as string) || "/formValidation"; set(statePath, { valid, errors }); return; } // Built-in: push (navigation) if (resolved.action === "push" && resolved.params) { const screen = resolved.params.screen as string; if (screen) { const currentScreen = get("/currentScreen") as string | undefined; const navStack = (get("/navStack") as string[] | undefined) ?? []; if (currentScreen) { set("/navStack", [...navStack, currentScreen]); } else { set("/navStack", [...navStack, ""]); } set("/currentScreen", screen); } return; } // Built-in: pop (navigation) if (resolved.action === "pop") { const navStack = (get("/navStack") as string[] | undefined) ?? []; if (navStack.length > 0) { const previousScreen = navStack[navStack.length - 1]; set("/navStack", navStack.slice(0, -1)); if (previousScreen) { set("/currentScreen", previousScreen); } else { set("/currentScreen", undefined); } } return; } const handler = handlers.value[resolved.action]; if (!handler) { console.warn(`No handler registered for action: ${resolved.action}`); return; } // If confirmation is required, show dialog first if (resolved.confirm) { await new Promise((resolve, reject) => { pendingConfirmation.value = { action: resolved, handler, resolve: () => { pendingConfirmation.value = null; resolve(); }, reject: () => { pendingConfirmation.value = null; reject(new Error("Action cancelled")); }, }; }); const addLoading = new Set(loadingActions.value); addLoading.add(resolved.action); loadingActions.value = addLoading; try { await executeAction({ action: resolved, handler, setState: set, navigate: props.navigate, executeAction: async (name) => { const subBinding: ActionBinding = { action: name }; await execute(subBinding); }, }); } finally { const removeLoading = new Set(loadingActions.value); removeLoading.delete(resolved.action); loadingActions.value = removeLoading; } return; } // Execute immediately const addLoading = new Set(loadingActions.value); addLoading.add(resolved.action); loadingActions.value = addLoading; try { await executeAction({ action: resolved, handler, setState: set, navigate: props.navigate, executeAction: async (name) => { const subBinding: ActionBinding = { action: name }; await execute(subBinding); }, }); } finally { const removeLoading = new Set(loadingActions.value); removeLoading.delete(resolved.action); loadingActions.value = removeLoading; } }; const confirm = () => pendingConfirmation.value?.resolve(); const cancel = () => pendingConfirmation.value?.reject(); provide(ACTIONS_KEY, { get handlers() { return handlers.value; }, get loadingActions() { return loadingActions.value; }, get pendingConfirmation() { return pendingConfirmation.value; }, execute, confirm, cancel, registerHandler, }); return () => slots.default?.(); }, }); /** * Composable to access action context */ export function useActions(): ActionContextValue { const ctx = inject(ACTIONS_KEY); if (!ctx) { throw new Error("useActions must be used within an ActionProvider"); } return ctx; } /** * Composable to execute an action binding */ export function useAction(binding: ActionBinding): { execute: () => Promise; isLoading: ComputedRef; } { const ctx = useActions(); return { execute: () => ctx.execute(binding), isLoading: computed(() => ctx.loadingActions.has(binding.action)), }; } // ============================================================================= // ConfirmDialog component // ============================================================================= /** * Props for ConfirmDialog component */ export interface ConfirmDialogProps { confirm: ActionConfirm; onConfirm: () => void; onCancel: () => void; } /** * Default confirmation dialog component */ export const ConfirmDialog = defineComponent({ name: "ConfirmDialog", props: { confirm: { type: Object as PropType, required: true, }, onConfirm: { type: Function as PropType<() => void>, required: true, }, onCancel: { type: Function as PropType<() => void>, required: true, }, }, setup(props) { return () => { const isDanger = props.confirm.variant === "danger"; return h( "div", { style: { position: "fixed", inset: "0", backgroundColor: "rgba(0, 0, 0, 0.5)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: "50", }, onClick: props.onCancel, }, [ h( "div", { style: { backgroundColor: "white", borderRadius: "8px", padding: "24px", maxWidth: "400px", width: "100%", boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.1)", }, onClick: (e: MouseEvent) => e.stopPropagation(), }, [ h( "h3", { style: { margin: "0 0 8px 0", fontSize: "18px", fontWeight: "600", }, }, props.confirm.title, ), h( "p", { style: { margin: "0 0 24px 0", color: "#6b7280" }, }, props.confirm.message, ), h( "div", { style: { display: "flex", gap: "12px", justifyContent: "flex-end", }, }, [ h( "button", { style: { padding: "8px 16px", borderRadius: "6px", border: "1px solid #d1d5db", backgroundColor: "white", cursor: "pointer", }, onClick: props.onCancel, }, props.confirm.cancelLabel ?? "Cancel", ), h( "button", { style: { padding: "8px 16px", borderRadius: "6px", border: "none", backgroundColor: isDanger ? "#dc2626" : "#3b82f6", color: "white", cursor: "pointer", }, onClick: props.onConfirm, }, props.confirm.confirmLabel ?? "Confirm", ), ], ), ], ), ], ); }; }, }); ================================================ FILE: packages/vue/src/composables/repeat-scope.ts ================================================ import { defineComponent, inject, provide, type PropType } from "vue"; /** * Repeat scope value provided to child elements inside a repeated element. */ export interface RepeatScopeValue { /** The current array item object */ item: unknown; /** Index of the current item in the array */ index: number; /** Absolute state path to the current array item (e.g. "/todos/0") — used for statePath two-way binding */ basePath: string; } const REPEAT_SCOPE_KEY = Symbol("json-render:repeat-scope"); /** * Provides repeat scope to child elements so $item and $index expressions resolve correctly. */ export const RepeatScopeProvider = defineComponent({ name: "RepeatScopeProvider", props: { item: { required: true, }, index: { type: Number, required: true, }, basePath: { type: String as PropType, required: true, }, }, setup(props, { slots }) { provide(REPEAT_SCOPE_KEY, props as RepeatScopeValue); return () => slots.default?.(); }, }); /** * Read the current repeat scope (or null if not inside a repeated element). */ export function useRepeatScope(): RepeatScopeValue | null { return ( inject( REPEAT_SCOPE_KEY, null as unknown as RepeatScopeValue, ) ?? null ); } ================================================ FILE: packages/vue/src/composables/state.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { defineComponent, h, type Component } from "vue"; import { mount } from "@vue/test-utils"; import { createStateStore } from "@json-render/core"; import { StateProvider, useStateStore } from "./state"; /** Mount a StateProvider with a child that captures the injected context. */ function withProvider( composable: () => T, props: Record = {}, ): { result: T } { let result!: T; const Child = defineComponent({ setup() { result = composable(); return () => h("div"); }, }); mount(StateProvider as Component, { props: props as any, slots: { default: () => h(Child) }, }); return { result }; } describe("StateProvider — provide/inject", () => { it("useStateStore() throws outside a provider", () => { // inject() returns undefined outside of component setup; our guard throws const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(() => useStateStore()).toThrow( "useStateStore must be used within a StateProvider", ); warn.mockRestore(); }); it("child receives the context from StateProvider", () => { const { result } = withProvider(() => useStateStore()); expect(result).toBeDefined(); expect(result.state).toBeDefined(); expect(typeof result.get).toBe("function"); expect(typeof result.set).toBe("function"); expect(typeof result.update).toBe("function"); }); it("state.value is a plain object (not a wrapper type)", () => { const { result } = withProvider(() => useStateStore(), { initialState: { x: 1 }, }); expect(result.state.value).toEqual({ x: 1 }); // Should be a plain object, not a Vue Proxy of a ShallowRef wrapper expect(typeof result.state.value).toBe("object"); }); }); describe("StateProvider (uncontrolled) — reactivity", () => { it("state.value reflects initialState on mount", () => { const { result } = withProvider(() => useStateStore(), { initialState: { count: 5 }, }); expect(result.state.value).toEqual({ count: 5 }); }); it("after set(), state.value is updated synchronously", () => { const { result } = withProvider(() => useStateStore(), { initialState: { x: 0 }, }); result.set("/x", 42); expect(result.state.value).toEqual({ x: 42 }); }); it("after update(), all paths are reflected in state.value", () => { const { result } = withProvider(() => useStateStore(), { initialState: {}, }); result.update({ "/a": 1, "/b": "hello" }); expect(result.state.value).toEqual({ a: 1, b: "hello" }); }); it("onStateChange is fired with the changes array on set", () => { const onChange = vi.fn(); const { result } = withProvider(() => useStateStore(), { initialState: {}, onStateChange: onChange, }); result.set("/name", "Alice"); expect(onChange).toHaveBeenCalledOnce(); expect(onChange).toHaveBeenCalledWith([{ path: "/name", value: "Alice" }]); }); it("onStateChange is fired once with all changed entries on update", () => { const onChange = vi.fn(); const { result } = withProvider(() => useStateStore(), { initialState: {}, onStateChange: onChange, }); result.update({ "/a": 1, "/b": 2 }); expect(onChange).toHaveBeenCalledOnce(); const [changes] = onChange.mock.calls[0]!; expect(changes).toEqual( expect.arrayContaining([ { path: "/a", value: 1 }, { path: "/b", value: 2 }, ]), ); }); }); describe("StateProvider (controlled mode)", () => { it("state.value reads from the external store snapshot", () => { const store = createStateStore({ x: 10 }); const { result } = withProvider(() => useStateStore(), { store }); expect(result.state.value).toEqual({ x: 10 }); }); it("set() writes through to the external store", () => { const store = createStateStore({ x: 0 }); const { result } = withProvider(() => useStateStore(), { store }); result.set("/x", 99); expect(store.getSnapshot()).toEqual({ x: 99 }); }); it("external store mutation triggers state.value update", () => { const store = createStateStore({ x: 0 }); const { result } = withProvider(() => useStateStore(), { store }); store.set("/x", 99); expect(result.state.value).toEqual({ x: 99 }); }); it("onStateChange is NOT called in controlled mode", () => { const store = createStateStore({ x: 0 }); const onChange = vi.fn(); const { result } = withProvider(() => useStateStore(), { store, onStateChange: onChange, }); result.set("/x", 99); expect(onChange).not.toHaveBeenCalled(); }); }); ================================================ FILE: packages/vue/src/composables/state.ts ================================================ import { computed, defineComponent, inject, onUnmounted, provide, ref, shallowRef, watch, type ComputedRef, type PropType, type ShallowRef, } from "vue"; import { createStateStore, getByPath, type StateModel, type StateStore, } from "@json-render/core"; import { flattenToPointers } from "@json-render/core/store-utils"; /** * State context value */ export interface StateContextValue { /** Reactive state snapshot — use state.value in reactive contexts */ state: ShallowRef; /** Get a value by path */ get: (path: string) => unknown; /** Set a value by path */ set: (path: string, value: unknown) => void; /** Update multiple values at once */ update: (updates: Record) => void; /** Return the live state snapshot from the underlying store (not through Vue reactivity). */ getSnapshot: () => StateModel; } const STATE_KEY = Symbol("json-render:state"); /** * Props for StateProvider */ export interface StateProviderProps { /** * External store that owns the state. When provided, the provider operates * in **controlled mode** — `initialState` and `onStateChange` are ignored * and the store is the single source of truth. */ store?: StateStore; /** Initial state model (used only in uncontrolled mode) */ initialState?: StateModel; /** * Callback when state changes (used only in uncontrolled mode). * Called once per `set` or `update` with all changed entries. */ onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; } /** * Provider for state model context. * * Supports two modes: * - **Controlled**: pass a `store` prop (e.g. backed by Redux / Zustand). * - **Uncontrolled** (default): omit `store` and optionally pass * `initialState` / `onStateChange`. */ export const StateProvider = defineComponent({ name: "StateProvider", props: { store: { type: Object as PropType, default: undefined, }, initialState: { type: Object as PropType, default: undefined, }, onStateChange: { type: Function as PropType< (changes: Array<{ path: string; value: unknown }>) => void >, default: undefined, }, }, setup(props, { slots }) { const isControlled = !!props.store; // Use external store (controlled) or create internal store (uncontrolled) const internalStore = !isControlled ? createStateStore(props.initialState ?? {}) : null; const store: StateStore = props.store ?? internalStore!; const state = shallowRef(store.getSnapshot()); const unsubscribe = store.subscribe(() => { state.value = store.getSnapshot(); }); onUnmounted(unsubscribe); // Sync external initialState changes (uncontrolled mode only) if (!isControlled) { let prevFlat: Record = props.initialState && Object.keys(props.initialState).length > 0 ? flattenToPointers(props.initialState) : {}; watch( () => props.initialState, (newInitialState) => { if (!newInitialState) return; const nextFlat = Object.keys(newInitialState).length > 0 ? flattenToPointers(newInitialState) : {}; const allKeys = new Set([ ...Object.keys(prevFlat), ...Object.keys(nextFlat), ]); const updates: Record = {}; for (const key of allKeys) { if (prevFlat[key] !== nextFlat[key]) { updates[key] = key in nextFlat ? nextFlat[key] : undefined; } } prevFlat = nextFlat; if (Object.keys(updates).length > 0) { store.update(updates); } }, ); } // Keep onStateChange in a ref so it always reads the latest callback const onStateChangeRef = ref(props.onStateChange); watch( () => props.onStateChange, (fn) => { onStateChangeRef.value = fn; }, ); const get = (path: string) => store.get(path); const getSnapshot = () => store.getSnapshot(); const set = (path: string, value: unknown) => { const prev = store.getSnapshot(); store.set(path, value); if (!isControlled && store.getSnapshot() !== prev) { onStateChangeRef.value?.([{ path, value }]); } }; const update = (updates: Record) => { const prev = store.getSnapshot(); store.update(updates); if (!isControlled && store.getSnapshot() !== prev) { const changes: Array<{ path: string; value: unknown }> = []; for (const [path, value] of Object.entries(updates)) { if (getByPath(prev, path) !== value) { changes.push({ path, value }); } } if (changes.length > 0) { onStateChangeRef.value?.(changes); } } }; provide(STATE_KEY, { state, get, set, update, getSnapshot, }); return () => slots.default?.(); }, }); /** * Composable to access the state context */ export function useStateStore(): StateContextValue { const ctx = inject(STATE_KEY); if (!ctx) { throw new Error("useStateStore must be used within a StateProvider"); } return ctx; } /** * Composable to get a value from the state model (reactive) */ export function useStateValue(path: string): ComputedRef { const { state } = useStateStore(); return computed(() => getByPath(state.value, path) as T | undefined); } /** * Composable to get and set a value from the state model by path. * * This is the path-based variant for use in arbitrary composables. For * registry components that receive `bindings` from the renderer, prefer * `useBoundProp` which reads the already-resolved prop value and writes back * to the bound path. */ export function useStateBinding( path: string, ): [ComputedRef, (value: T) => void] { const { state, set } = useStateStore(); const value = computed(() => getByPath(state.value, path) as T | undefined); const setValue = (newValue: T) => set(path, newValue); return [value, setValue]; } ================================================ FILE: packages/vue/src/composables/validation.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { defineComponent, h, type Component } from "vue"; import { mount } from "@vue/test-utils"; import { StateProvider } from "./state"; import { ValidationProvider, useOptionalValidation, useValidation, useFieldValidation, } from "./validation"; /** Mount StateProvider → ValidationProvider with a child that captures context. */ function withProviders( composable: () => T, initialState: Record = {}, ): { result: T } { let result!: T; const Child = defineComponent({ setup() { result = composable(); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState } as any, slots: { default: () => h(ValidationProvider as Component, null, { default: () => h(Child), }), }, }); return { result }; } /** Mount only inside StateProvider (no ValidationProvider) */ function withStateOnly(composable: () => T): { result: T } { let result!: T; const Child = defineComponent({ setup() { result = composable(); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState: {} } as any, slots: { default: () => h(Child) }, }); return { result }; } describe("ValidationProvider — provide/inject", () => { it("useValidation() throws outside a provider", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(() => useValidation()).toThrow( "useValidation must be used within a ValidationProvider", ); warn.mockRestore(); }); }); describe("useOptionalValidation", () => { it("returns null outside a ValidationProvider", () => { const { result } = withStateOnly(() => useOptionalValidation()); expect(result).toBeNull(); }); it("returns context inside a ValidationProvider", () => { const { result } = withProviders(() => useOptionalValidation()); expect(result).not.toBeNull(); expect(typeof result!.validate).toBe("function"); expect(typeof result!.validateAll).toBe("function"); }); }); describe("useFieldValidation — lifecycle", () => { it("after validate(), isValid and errors reflect the result", () => { const { result } = withProviders( () => useFieldValidation("/name", { checks: [{ type: "required", message: "Name is required" }], }), { name: "" }, ); const validationResult = result.validate(); expect(validationResult.valid).toBe(false); expect(validationResult.errors).toContain("Name is required"); }); it("after validate() with valid value, isValid is true", () => { const { result } = withProviders( () => useFieldValidation("/name", { checks: [{ type: "required", message: "Name is required" }], }), { name: "Alice" }, ); const validationResult = result.validate(); expect(validationResult.valid).toBe(true); expect(validationResult.errors).toHaveLength(0); }); it("touch() sets touched: true in the validation context", () => { let validationCtx!: ReturnType; let fieldCtx!: ReturnType; const Child = defineComponent({ setup() { validationCtx = useValidation(); fieldCtx = useFieldValidation("/email"); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState: {} } as any, slots: { default: () => h(ValidationProvider as Component, null, { default: () => h(Child), }), }, }); fieldCtx.touch(); expect(validationCtx.fieldStates["/email"]?.touched).toBe(true); }); it("clear() resets the field state from the validation context", () => { let validationCtx!: ReturnType; let fieldCtx!: ReturnType; const Child = defineComponent({ setup() { validationCtx = useValidation(); fieldCtx = useFieldValidation("/email", { checks: [{ type: "required", message: "Required" }], }); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState: { email: "" } } as any, slots: { default: () => h(ValidationProvider as Component, null, { default: () => h(Child), }), }, }); fieldCtx.validate(); // populate fieldStates fieldCtx.clear(); expect(validationCtx.fieldStates["/email"]).toBeUndefined(); }); it("validateAll() returns true when all registered fields pass", () => { let validationCtx!: ReturnType; const Child = defineComponent({ setup() { validationCtx = useValidation(); useFieldValidation("/name", { checks: [{ type: "required", message: "Required" }], }); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState: { name: "Alice" } } as any, slots: { default: () => h(ValidationProvider as Component, null, { default: () => h(Child), }), }, }); expect(validationCtx.validateAll()).toBe(true); }); it("validateAll() returns false when any field fails", () => { let validationCtx!: ReturnType; const Child = defineComponent({ setup() { validationCtx = useValidation(); useFieldValidation("/name", { checks: [{ type: "required", message: "Required" }], }); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState: { name: "" } } as any, slots: { default: () => h(ValidationProvider as Component, null, { default: () => h(Child), }), }, }); expect(validationCtx.validateAll()).toBe(false); }); }); ================================================ FILE: packages/vue/src/composables/validation.ts ================================================ import { computed, defineComponent, inject, onMounted, onUnmounted, provide, ref, type ComputedRef, type PropType, } from "vue"; import { runValidation, type ValidationConfig, type ValidationFunction, type ValidationResult, } from "@json-render/core"; import { useStateStore } from "./state"; /** * Field validation state */ export interface FieldValidationState { touched: boolean; validated: boolean; result: ValidationResult | null; } /** * Validation context value */ export interface ValidationContextValue { customFunctions: Record; fieldStates: Record; validate: (path: string, config: ValidationConfig) => ValidationResult; touch: (path: string) => void; clear: (path: string) => void; validateAll: () => boolean; registerField: (path: string, config: ValidationConfig) => void; } const VALIDATION_KEY = Symbol("json-render:validation"); export interface ValidationProviderProps { customFunctions?: Record; } /** * Compare two args records shallowly. */ function dynamicArgsEqual( a: Record | undefined, b: Record | undefined, ): boolean { if (a === b) return true; if (!a || !b) return false; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { const va = a[key]; const vb = b[key]; if (va === vb) continue; if ( typeof va === "object" && va !== null && typeof vb === "object" && vb !== null ) { const sa = (va as Record).$state; const sb = (vb as Record).$state; if (typeof sa === "string" && sa === sb) continue; } return false; } return true; } /** * Structural equality check for ValidationConfig. */ function validationConfigEqual( a: ValidationConfig, b: ValidationConfig, ): boolean { if (a === b) return true; if (a.validateOn !== b.validateOn) return false; const ac = a.checks ?? []; const bc = b.checks ?? []; if (ac.length !== bc.length) return false; for (let i = 0; i < ac.length; i++) { const ca = ac[i]!; const cb = bc[i]!; if (ca.type !== cb.type) return false; if (ca.message !== cb.message) return false; if (!dynamicArgsEqual(ca.args, cb.args)) return false; } return true; } /** * Provider for validation */ export const ValidationProvider = defineComponent({ name: "ValidationProvider", props: { customFunctions: { type: Object as PropType>, default: () => ({}), }, }, setup(props, { slots }) { const { state } = useStateStore(); const fieldStates = ref>({}); const fieldConfigs = ref>({}); const registerField = (path: string, config: ValidationConfig) => { const existing = fieldConfigs.value[path]; if (existing && validationConfigEqual(existing, config)) return; fieldConfigs.value = { ...fieldConfigs.value, [path]: config }; }; const validate = ( path: string, config: ValidationConfig, ): ValidationResult => { const currentState = state.value; const segments = path.split("/").filter(Boolean); let value: unknown = currentState; for (const seg of segments) { if (value != null && typeof value === "object") { value = (value as Record)[seg]; } else { value = undefined; break; } } const result = runValidation(config, { value, stateModel: currentState, customFunctions: props.customFunctions, }); fieldStates.value = { ...fieldStates.value, [path]: { touched: fieldStates.value[path]?.touched ?? true, validated: true, result, }, }; return result; }; const touch = (path: string) => { fieldStates.value = { ...fieldStates.value, [path]: { ...fieldStates.value[path], touched: true, validated: fieldStates.value[path]?.validated ?? false, result: fieldStates.value[path]?.result ?? null, }, }; }; const clear = (path: string) => { const { [path]: _, ...rest } = fieldStates.value; fieldStates.value = rest; }; const validateAll = (): boolean => { let allValid = true; for (const [path, config] of Object.entries(fieldConfigs.value)) { const result = validate(path, config); if (!result.valid) { allValid = false; } } return allValid; }; provide(VALIDATION_KEY, { get customFunctions() { return props.customFunctions; }, get fieldStates() { return fieldStates.value; }, validate, touch, clear, validateAll, registerField, }); return () => slots.default?.(); }, }); /** * Composable to access validation context (or null if not inside a ValidationProvider). * Useful for components that optionally participate in form validation. */ export function useOptionalValidation(): ValidationContextValue | null { return ( inject( VALIDATION_KEY, null as unknown as ValidationContextValue, ) ?? null ); } /** * Composable to access validation context */ export function useValidation(): ValidationContextValue { const ctx = inject(VALIDATION_KEY); if (!ctx) { throw new Error("useValidation must be used within a ValidationProvider"); } return ctx; } /** * Composable to get validation state for a field */ export function useFieldValidation( path: string, config?: ValidationConfig, ): { state: ComputedRef; validate: () => ValidationResult; touch: () => void; clear: () => void; errors: ComputedRef; isValid: ComputedRef; } { const ctx = useValidation(); onMounted(() => { if (path && config) { ctx.registerField(path, config); } }); onUnmounted(() => { ctx.clear(path); }); const defaultState: FieldValidationState = { touched: false, validated: false, result: null, }; return { state: computed(() => ctx.fieldStates[path] ?? defaultState), validate: () => ctx.validate(path, config ?? { checks: [] }), touch: () => ctx.touch(path), clear: () => ctx.clear(path), errors: computed(() => ctx.fieldStates[path]?.result?.errors ?? []), isValid: computed(() => ctx.fieldStates[path]?.result?.valid ?? true), }; } ================================================ FILE: packages/vue/src/composables/visibility.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { defineComponent, h, type Component, type ComputedRef } from "vue"; import { mount } from "@vue/test-utils"; import { StateProvider, useStateStore } from "./state"; import { VisibilityProvider, useVisibility, useIsVisible } from "./visibility"; /** Mount StateProvider → VisibilityProvider with a child that captures context. */ function withProviders( composable: () => T, initialState: Record = {}, ): { result: T } { let result!: T; const Child = defineComponent({ setup() { result = composable(); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState } as any, slots: { default: () => h(VisibilityProvider as Component, null, { default: () => h(Child), }), }, }); return { result }; } describe("VisibilityProvider — provide/inject", () => { it("useVisibility() throws outside a VisibilityProvider", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(() => useVisibility()).toThrow( "useVisibility must be used within a VisibilityProvider", ); warn.mockRestore(); }); it("useVisibility() returns a context with isVisible and ctx", () => { const { result } = withProviders(() => useVisibility()); expect(typeof result.isVisible).toBe("function"); expect(result.ctx).toBeDefined(); expect(result.ctx.value).toBeDefined(); }); }); describe("useIsVisible — state integration", () => { it("undefined condition returns true", () => { const { result } = withProviders(() => useVisibility()); expect(result.isVisible(undefined)).toBe(true); }); it("{ $state: '/flag' } returns true when state flag is truthy", () => { const { result } = withProviders(() => useVisibility(), { flag: true }); expect(result.isVisible({ $state: "/flag" })).toBe(true); }); it("{ $state: '/flag' } returns false when state flag is falsy", () => { const { result } = withProviders(() => useVisibility(), { flag: false }); expect(result.isVisible({ $state: "/flag" })).toBe(false); }); it("ctx is a ComputedRef whose .value reflects current state", () => { const { result } = withProviders(() => useVisibility(), { count: 3 }); expect(result.ctx.value.stateModel).toEqual({ count: 3 }); }); }); describe("useIsVisible — reactivity", () => { it("returns a ComputedRef that updates when state changes", () => { let storeCtx!: ReturnType; let isVisible!: ComputedRef; const Child = defineComponent({ setup() { storeCtx = useStateStore(); isVisible = useIsVisible({ $state: "/flag" }); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState: { flag: false } } as any, slots: { default: () => h(VisibilityProvider as Component, null, { default: () => h(Child), }), }, }); expect(isVisible.value).toBe(false); storeCtx.set("/flag", true); expect(isVisible.value).toBe(true); }); }); ================================================ FILE: packages/vue/src/composables/visibility.ts ================================================ import { computed, defineComponent, inject, provide, type ComputedRef, } from "vue"; import { evaluateVisibility, type VisibilityCondition, type VisibilityContext as CoreVisibilityContext, } from "@json-render/core"; import { useStateStore } from "./state"; /** * Visibility context value */ export interface VisibilityContextValue { /** Evaluate a visibility condition */ isVisible: (condition: VisibilityCondition | undefined) => boolean; /** The underlying visibility context (reactive) */ ctx: ComputedRef; } const VISIBILITY_KEY = Symbol("json-render:visibility"); /** * Provider for visibility evaluation */ export const VisibilityProvider = defineComponent({ name: "VisibilityProvider", setup(_, { slots }) { const { state } = useStateStore(); const ctx = computed(() => ({ stateModel: state.value, })); const isVisible = (condition: VisibilityCondition | undefined): boolean => evaluateVisibility(condition, ctx.value); provide(VISIBILITY_KEY, { isVisible, ctx }); return () => slots.default?.(); }, }); /** * Composable to access visibility evaluation */ export function useVisibility(): VisibilityContextValue { const ctx = inject(VISIBILITY_KEY); if (!ctx) { throw new Error("useVisibility must be used within a VisibilityProvider"); } return ctx; } /** * Composable to check if a condition is visible. Returns a reactive * `ComputedRef` so the result updates whenever state changes. */ export function useIsVisible( condition: VisibilityCondition | undefined, ): ComputedRef { const { ctx } = useVisibility(); return computed(() => evaluateVisibility(condition, ctx.value)); } ================================================ FILE: packages/vue/src/dynamic-forms.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { defineComponent, h, nextTick, type Component } from "vue"; import { mount } from "@vue/test-utils"; import type { Spec } from "@json-render/core"; import { StateProvider, useStateStore } from "./composables/state"; import { VisibilityProvider } from "./composables/visibility"; import { ActionProvider } from "./composables/actions"; import { ValidationProvider } from "./composables/validation"; import { useFieldValidation } from "./composables/validation"; import { useBoundProp } from "./hooks"; import { Renderer, JSONUIProvider, type ComponentRegistry, type ComponentRenderProps, } from "./renderer"; // ============================================================================= // Stub components // ============================================================================= const Button = defineComponent({ name: "Button", props: { element: { type: Object, required: true }, emit: { type: Function, required: true }, on: { type: Function, required: true }, bindings: { type: Object, default: undefined }, loading: { type: Boolean, default: undefined }, }, setup(props) { return () => h( "button", { "data-testid": "btn", onClick: () => props.emit("press") }, String((props.element as any).props?.label ?? ""), ); }, }); const Text = defineComponent({ name: "Text", props: { element: { type: Object, required: true }, emit: { type: Function, required: true }, on: { type: Function, required: true }, bindings: { type: Object, default: undefined }, loading: { type: Boolean, default: undefined }, }, setup(props) { return () => { const value = (props.element as any).props?.text; return h( "span", { "data-testid": "text" }, value == null ? "" : typeof value === "string" ? value : JSON.stringify(value), ); }; }, }); const Stack = defineComponent({ name: "Stack", props: { element: { type: Object, required: true }, emit: { type: Function, required: true }, on: { type: Function, required: true }, bindings: { type: Object, default: undefined }, loading: { type: Boolean, default: undefined }, }, setup(_props, { slots }) { return () => h("div", { "data-testid": "stack" }, slots.default?.()); }, }); const InputField = defineComponent({ name: "InputField", props: { element: { type: Object, required: true }, emit: { type: Function, required: true }, on: { type: Function, required: true }, bindings: { type: Object, default: undefined }, loading: { type: Boolean, default: undefined }, }, setup(props) { const elProps = (props.element as any).props ?? {}; const bindingPath = (props.bindings as Record)?.value; const [boundValue, setBoundValue] = useBoundProp( elProps.value as string | undefined, bindingPath, ); const hasValidation = !!(bindingPath && elProps.checks?.length); const config = hasValidation ? { checks: elProps.checks ?? [] } : undefined; const { errors } = useFieldValidation(bindingPath ?? "", config); return () => h("div", null, [ elProps.label ? h("label", null, elProps.label) : null, h("input", { "data-testid": "input", value: boundValue ?? "", onInput: (e: Event) => setBoundValue((e.target as HTMLInputElement).value), }), errors.value.length > 0 ? h("span", { "data-testid": "input-error" }, errors.value[0]) : null, ]); }, }); const Select = defineComponent({ name: "Select", props: { element: { type: Object, required: true }, emit: { type: Function, required: true }, on: { type: Function, required: true }, bindings: { type: Object, default: undefined }, loading: { type: Boolean, default: undefined }, }, setup(props) { const elProps = (props.element as any).props ?? {}; const bindingPath = (props.bindings as Record)?.value; const [boundValue] = useBoundProp( elProps.value as string | undefined, bindingPath, ); return () => h("span", { "data-testid": "select-value" }, boundValue ?? ""); }, }); const registry: ComponentRegistry = { Button: Button as unknown as Component, Text: Text as unknown as Component, Input: InputField as unknown as Component, Select: Select as unknown as Component, Stack: Stack as unknown as Component, }; // State probe to read state from tests const StateProbe = defineComponent({ name: "StateProbe", setup() { const { state } = useStateStore(); return () => h("pre", { "data-testid": "state-probe" }, JSON.stringify(state.value)); }, }); function getState(wrapper: ReturnType): Record { return JSON.parse(wrapper.find("[data-testid='state-probe']").text()); } // ============================================================================= // Mount helper // ============================================================================= function mountWithProviders( spec: Spec, opts: { functions?: Record) => unknown>; handlers?: Record< string, (params: Record) => Promise | void >; initialState?: Record; } = {}, ) { return mount(JSONUIProvider as Component, { props: { registry, initialState: opts.initialState ?? spec.state ?? {}, functions: opts.functions, handlers: opts.handlers, } as any, slots: { default: () => [h(Renderer, { spec, registry }), h(StateProbe)], }, }); } // ============================================================================= // $computed expressions in rendering // ============================================================================= describe("$computed expressions in rendering", () => { it("resolves a $computed prop using provided functions", () => { const spec: Spec = { state: { first: "Jane", last: "Doe" }, root: "main", elements: { main: { type: "Text", props: { text: { $computed: "fullName", args: { first: { $state: "/first" }, last: { $state: "/last" }, }, }, }, children: [], }, }, }; const functions = { fullName: (args: Record) => `${args.first} ${args.last}`, }; const wrapper = mountWithProviders(spec, { functions }); expect(wrapper.find("[data-testid='text']").text()).toBe("Jane Doe"); }); it("renders gracefully when functions prop is omitted", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const spec: Spec = { state: {}, root: "main", elements: { main: { type: "Text", props: { text: { $computed: "missing" }, }, children: [], }, }, }; const wrapper = mountWithProviders(spec); expect(wrapper.find("[data-testid='text']").text()).toBe(""); warnSpy.mockRestore(); }); }); // ============================================================================= // Watchers // ============================================================================= describe("watchers (watch field)", () => { it("does not fire on initial render, fires when watched state changes", async () => { const loadCities = vi.fn(); const spec: Spec = { state: { form: { country: "" }, citiesLoaded: false }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["btn", "watcher"], }, btn: { type: "Button", props: { label: "Set Country" }, on: { press: [ { action: "setState", params: { statePath: "/form/country", value: "US" }, }, ], }, children: [], }, watcher: { type: "Select", props: { value: { $state: "/form/country" } }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } }, }, }, children: [], }, }, }; const wrapper = mountWithProviders(spec, { handlers: { loadCities } }); expect(loadCities).not.toHaveBeenCalled(); await wrapper.find("[data-testid='btn']").trigger("click"); await nextTick(); await nextTick(); expect(loadCities).toHaveBeenCalledTimes(1); expect(loadCities).toHaveBeenCalledWith( expect.objectContaining({ country: "US" }), ); }); it("fires multiple action bindings on the same watch path", async () => { const action1 = vi.fn(); const action2 = vi.fn(); const spec: Spec = { state: { value: "a" }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["btn", "watcher"], }, btn: { type: "Button", props: { label: "Change" }, on: { press: [ { action: "setState", params: { statePath: "/value", value: "b" }, }, ], }, children: [], }, watcher: { type: "Text", props: { text: { $state: "/value" } }, watch: { "/value": [{ action: "action1" }, { action: "action2" }], }, children: [], }, }, }; const wrapper = mountWithProviders(spec, { handlers: { action1, action2 }, }); await wrapper.find("[data-testid='btn']").trigger("click"); await nextTick(); await nextTick(); expect(action1).toHaveBeenCalledTimes(1); expect(action2).toHaveBeenCalledTimes(1); }); }); // ============================================================================= // validateForm action // ============================================================================= describe("validateForm action", () => { it("writes { valid: false, errors } when a required field is empty", async () => { const spec: Spec = { state: { form: { email: "" }, result: null }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["emailInput", "submitBtn"], }, emailInput: { type: "Input", props: { label: "Email", value: { $bindState: "/form/email" }, checks: [{ type: "required", message: "Email is required" }], }, children: [], }, submitBtn: { type: "Button", props: { label: "Submit" }, on: { press: [ { action: "validateForm", params: { statePath: "/result" }, }, ], }, children: [], }, }, }; const wrapper = mountWithProviders(spec); await wrapper.find("[data-testid='btn']").trigger("click"); await nextTick(); const state = getState(wrapper); expect(state.result).toEqual({ valid: false, errors: { "/form/email": ["Email is required"] }, }); }); it("writes { valid: true, errors: {} } when all fields pass validation", async () => { const spec: Spec = { state: { form: { email: "test@example.com" }, result: null }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["emailInput", "submitBtn"], }, emailInput: { type: "Input", props: { label: "Email", value: { $bindState: "/form/email" }, checks: [{ type: "required", message: "Email is required" }], }, children: [], }, submitBtn: { type: "Button", props: { label: "Submit" }, on: { press: [ { action: "validateForm", params: { statePath: "/result" }, }, ], }, children: [], }, }, }; const wrapper = mountWithProviders(spec); await wrapper.find("[data-testid='btn']").trigger("click"); await nextTick(); const state = getState(wrapper); expect(state.result).toEqual({ valid: true, errors: {} }); }); it("defaults to /formValidation when no statePath is provided", async () => { const spec: Spec = { state: { form: { name: "filled" } }, root: "wrapper", elements: { wrapper: { type: "Stack", props: {}, children: ["nameInput", "submitBtn"], }, nameInput: { type: "Input", props: { label: "Name", value: { $bindState: "/form/name" }, checks: [{ type: "required", message: "Required" }], }, children: [], }, submitBtn: { type: "Button", props: { label: "Submit" }, on: { press: [{ action: "validateForm" }], }, children: [], }, }, }; const wrapper = mountWithProviders(spec); await wrapper.find("[data-testid='btn']").trigger("click"); await nextTick(); const state = getState(wrapper); expect(state.formValidation).toEqual({ valid: true, errors: {} }); }); }); ================================================ FILE: packages/vue/src/hooks.test.ts ================================================ import { describe, it, expect, vi, afterEach } from "vitest"; import { defineComponent, h, ref, type Component } from "vue"; import { mount } from "@vue/test-utils"; import { SPEC_DATA_PART_TYPE } from "@json-render/core"; import { StateProvider, useStateStore } from "./composables/state"; import { flatToTree, buildSpecFromParts, getTextFromParts, useBoundProp, useUIStream, useJsonRenderMessage, type DataPart, } from "./hooks"; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- /** Mount inside a StateProvider and capture a composable's result. */ function withStateProvider( composable: () => T, initialState: Record = {}, ): { result: T } { let result!: T; const Child = defineComponent({ setup() { result = composable(); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState } as any, slots: { default: () => h(Child) }, }); return { result }; } /** Create a simple ReadableStream from a string (for fetch mocks). */ function makeReadableStream(text: string): ReadableStream { const encoder = new TextEncoder(); return new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(text)); controller.close(); }, }); } // --------------------------------------------------------------------------- // flatToTree // --------------------------------------------------------------------------- describe("flatToTree", () => { it("converts flat elements into a Spec with root and children", () => { const elements = [ { key: "root", type: "Stack", props: {}, parentKey: undefined }, { key: "child1", type: "Text", props: { content: "hello" }, parentKey: "root", }, ]; const spec = flatToTree(elements as any); expect(spec.root).toBe("root"); expect(spec.elements["root"]?.children).toContain("child1"); expect(spec.elements["child1"]).toBeDefined(); }); it("handles a single root element with no children", () => { const elements = [ { key: "root", type: "Text", props: {}, parentKey: undefined }, ]; const spec = flatToTree(elements as any); expect(spec.root).toBe("root"); expect(spec.elements["root"]?.children).toEqual([]); }); }); // --------------------------------------------------------------------------- // buildSpecFromParts // --------------------------------------------------------------------------- describe("buildSpecFromParts", () => { it("returns null when no spec parts present", () => { const parts: DataPart[] = [{ type: "text", text: "hello" }]; expect(buildSpecFromParts(parts)).toBeNull(); }); it("returns null for empty array", () => { expect(buildSpecFromParts([])).toBeNull(); }); it("returns a Spec when a snapshot spec data part is present", () => { const part: DataPart = { type: SPEC_DATA_PART_TYPE, data: { type: "flat", spec: { root: "r", elements: { r: { type: "Text", props: { content: "hi" } } }, }, }, }; const result = buildSpecFromParts([part]); expect(result?.root).toBe("r"); expect(result?.elements["r"]).toBeDefined(); }); it("applies patch operations incrementally", () => { const parts: DataPart[] = [ { type: SPEC_DATA_PART_TYPE, data: { type: "patch", patch: { op: "add", path: "/root", value: "myRoot" }, }, }, ]; const result = buildSpecFromParts(parts); expect(result?.root).toBe("myRoot"); }); it("skips malformed data parts silently", () => { const parts: DataPart[] = [ { type: SPEC_DATA_PART_TYPE, data: { type: "unknown" } }, ]; expect(buildSpecFromParts(parts)).toBeNull(); }); }); // --------------------------------------------------------------------------- // getTextFromParts // --------------------------------------------------------------------------- describe("getTextFromParts", () => { it("concatenates text parts with double newlines", () => { const parts: DataPart[] = [ { type: "text", text: "hello" }, { type: "text", text: "world" }, ]; expect(getTextFromParts(parts)).toBe("hello\n\nworld"); }); it("ignores non-text parts", () => { const parts: DataPart[] = [ { type: "other", data: {} }, { type: "text", text: "hi" }, ]; expect(getTextFromParts(parts)).toBe("hi"); }); it("returns empty string for empty array", () => { expect(getTextFromParts([])).toBe(""); }); it("filters out empty/whitespace text parts", () => { const parts: DataPart[] = [ { type: "text", text: " " }, { type: "text", text: "real" }, ]; expect(getTextFromParts(parts)).toBe("real"); }); }); // --------------------------------------------------------------------------- // useBoundProp // --------------------------------------------------------------------------- describe("useBoundProp", () => { it("returns the prop value and a no-op setter when no binding path", () => { const { result } = withStateProvider(() => useBoundProp("hello", undefined), ); const [value, setValue] = result; expect(value).toBe("hello"); expect(() => setValue("new")).not.toThrow(); }); it("returns undefined prop value when propValue is undefined", () => { const { result } = withStateProvider(() => useBoundProp(undefined, undefined), ); expect(result[0]).toBeUndefined(); }); it("setter writes to state at the binding path", () => { let storeCtx!: ReturnType; let setter!: (v: string) => void; const Child = defineComponent({ setup() { storeCtx = useStateStore(); const [, s] = useBoundProp("Alice", "/name"); setter = s; return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState: { name: "Alice" } } as any, slots: { default: () => h(Child) }, }); setter("Bob"); expect(storeCtx.get("/name")).toBe("Bob"); }); }); // --------------------------------------------------------------------------- // useUIStream // --------------------------------------------------------------------------- describe("useUIStream", () => { afterEach(() => { vi.unstubAllGlobals(); }); it("send() sets isStreaming true then false after completion", async () => { const patchLine = JSON.stringify({ op: "add", path: "/root", value: "myRoot" }) + "\n" + JSON.stringify({ op: "add", path: "/elements/myRoot", value: { type: "Text", props: {} }, }) + "\n"; vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: true, body: makeReadableStream(patchLine), }), ); let streamResult!: ReturnType; const Child = defineComponent({ setup() { streamResult = useUIStream({ api: "/api/ui" }); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState: {} } as any, slots: { default: () => h(Child) }, }); expect(streamResult.isStreaming.value).toBe(false); const promise = streamResult.send("build me a UI"); expect(streamResult.isStreaming.value).toBe(true); await promise; expect(streamResult.isStreaming.value).toBe(false); expect(streamResult.spec.value?.root).toBe("myRoot"); }); it("error is set when fetch fails", async () => { vi.stubGlobal( "fetch", vi.fn().mockRejectedValue(new Error("Network error")), ); let streamResult!: ReturnType; const Child = defineComponent({ setup() { streamResult = useUIStream({ api: "/api/ui" }); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState: {} } as any, slots: { default: () => h(Child) }, }); await streamResult.send("fail"); expect(streamResult.error.value?.message).toBe("Network error"); }); it("clear() resets spec and error", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("oops"))); let streamResult!: ReturnType; const Child = defineComponent({ setup() { streamResult = useUIStream({ api: "/api/ui" }); return () => h("div"); }, }); mount(StateProvider as Component, { props: { initialState: {} } as any, slots: { default: () => h(Child) }, }); await streamResult.send("fail"); expect(streamResult.error.value).not.toBeNull(); streamResult.clear(); expect(streamResult.error.value).toBeNull(); expect(streamResult.spec.value).toBeNull(); }); }); // --------------------------------------------------------------------------- // useJsonRenderMessage // --------------------------------------------------------------------------- describe("useJsonRenderMessage", () => { it("returns null spec and false hasSpec when no spec parts", () => { const { spec, hasSpec } = useJsonRenderMessage([ { type: "text", text: "hi" }, ]); expect(spec.value).toBeNull(); expect(hasSpec.value).toBe(false); }); it("extracts text from text parts", () => { const { text } = useJsonRenderMessage([ { type: "text", text: "hello" }, { type: "text", text: "world" }, ]); expect(text.value).toBe("hello\n\nworld"); }); it("is reactive when passed a Ref", () => { const parts = ref([]); const { text, spec, hasSpec } = useJsonRenderMessage(parts); expect(text.value).toBe(""); expect(spec.value).toBeNull(); parts.value = [{ type: "text", text: "hello" }]; expect(text.value).toBe("hello"); parts.value = [ ...parts.value, { type: SPEC_DATA_PART_TYPE, data: { type: "flat", spec: { root: "r", elements: { r: { type: "Text", props: {} } } }, }, }, ]; expect(spec.value?.root).toBe("r"); expect(hasSpec.value).toBe(true); }); }); ================================================ FILE: packages/vue/src/hooks.ts ================================================ import { ref, shallowRef, computed, onUnmounted, isRef, type Ref, type ComputedRef, } from "vue"; import { useStateStore } from "./composables/state"; import type { Spec, UIElement, FlatElement, JsonPatch, SpecDataPart, } from "@json-render/core"; import { setByPath, getByPath, addByPath, removeByPath, createMixedStreamParser, applySpecPatch, nestedToFlat, SPEC_DATA_PART_TYPE, } from "@json-render/core"; /** * Token usage metadata from AI generation */ export interface TokenUsage { promptTokens: number; completionTokens: number; totalTokens: number; } /** * Parse result for a single line -- either a patch or usage metadata */ type ParsedLine = | { type: "patch"; patch: JsonPatch } | { type: "usage"; usage: TokenUsage } | null; /** * Parse a single JSON line (patch or metadata) */ function parseLine(line: string): ParsedLine { try { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("//")) { return null; } const parsed = JSON.parse(trimmed); // Check for usage metadata if (parsed.__meta === "usage") { return { type: "usage", usage: { promptTokens: parsed.promptTokens ?? 0, completionTokens: parsed.completionTokens ?? 0, totalTokens: parsed.totalTokens ?? 0, }, }; } return { type: "patch", patch: parsed as JsonPatch }; } catch { return null; } } /** * Set a value at a spec path (for add/replace operations). */ function setSpecValue(newSpec: Spec, path: string, value: unknown): void { if (path === "/root") { newSpec.root = value as string; return; } if (path === "/state") { newSpec.state = value as Record; return; } if (path.startsWith("/state/")) { if (!newSpec.state) newSpec.state = {}; const statePath = path.slice("/state".length); // e.g. "/posts" setByPath(newSpec.state as Record, statePath, value); return; } if (path.startsWith("/elements/")) { const pathParts = path.slice("/elements/".length).split("/"); const elementKey = pathParts[0]; if (!elementKey) return; if (pathParts.length === 1) { newSpec.elements[elementKey] = value as UIElement; } else { const element = newSpec.elements[elementKey]; if (element) { const propPath = "/" + pathParts.slice(1).join("/"); const newElement = { ...element }; setByPath( newElement as unknown as Record, propPath, value, ); newSpec.elements[elementKey] = newElement; } } } } /** * Remove a value at a spec path. */ function removeSpecValue(newSpec: Spec, path: string): void { if (path === "/state") { delete newSpec.state; return; } if (path.startsWith("/state/") && newSpec.state) { const statePath = path.slice("/state".length); removeByPath(newSpec.state as Record, statePath); return; } if (path.startsWith("/elements/")) { const pathParts = path.slice("/elements/".length).split("/"); const elementKey = pathParts[0]; if (!elementKey) return; if (pathParts.length === 1) { const { [elementKey]: _, ...rest } = newSpec.elements; newSpec.elements = rest; } else { const element = newSpec.elements[elementKey]; if (element) { const propPath = "/" + pathParts.slice(1).join("/"); const newElement = { ...element }; removeByPath( newElement as unknown as Record, propPath, ); newSpec.elements[elementKey] = newElement; } } } } /** * Get a value at a spec path. */ function getSpecValue(spec: Spec, path: string): unknown { if (path === "/root") return spec.root; if (path === "/state") return spec.state; if (path.startsWith("/state/") && spec.state) { const statePath = path.slice("/state".length); return getByPath(spec.state as Record, statePath); } return getByPath(spec as unknown as Record, path); } /** * Apply an RFC 6902 JSON patch to the current spec. * Supports add, remove, replace, move, copy, and test operations. */ function applyPatch(spec: Spec, patch: JsonPatch): Spec { const newSpec = { ...spec, elements: { ...spec.elements }, ...(spec.state ? { state: { ...spec.state } } : {}), }; switch (patch.op) { case "add": case "replace": { setSpecValue(newSpec, patch.path, patch.value); break; } case "remove": { removeSpecValue(newSpec, patch.path); break; } case "move": { if (!patch.from) break; const moveValue = getSpecValue(newSpec, patch.from); removeSpecValue(newSpec, patch.from); setSpecValue(newSpec, patch.path, moveValue); break; } case "copy": { if (!patch.from) break; const copyValue = getSpecValue(newSpec, patch.from); setSpecValue(newSpec, patch.path, copyValue); break; } case "test": { // test is a no-op for rendering purposes (validation only) break; } } return newSpec; } /** * Options for useUIStream */ export interface UseUIStreamOptions { /** API endpoint */ api: string; /** Callback when complete */ onComplete?: (spec: Spec) => void; /** Callback on error */ onError?: (error: Error) => void; } /** * Return type for useUIStream */ export interface UseUIStreamReturn { /** Current UI spec */ spec: Ref; /** Whether currently streaming */ isStreaming: Ref; /** Error if any */ error: Ref; /** Token usage from the last generation */ usage: Ref; /** Raw JSONL lines received from the stream (JSON patch lines) */ rawLines: Ref; /** Send a prompt to generate UI */ send: (prompt: string, context?: Record) => Promise; /** Clear the current spec */ clear: () => void; } /** * Composable for streaming UI generation */ export function useUIStream({ api, onComplete, onError, }: UseUIStreamOptions): UseUIStreamReturn { const spec = shallowRef(null); const isStreaming = ref(false); const error = ref(null); const usage = ref(null); const rawLines = ref([]); const onCompleteRef = ref(onComplete); const onErrorRef = ref(onError); let abortController: AbortController | null = null; const clear = () => { spec.value = null; error.value = null; usage.value = null; rawLines.value = []; }; const send = async ( prompt: string, context?: Record, ): Promise => { // Abort any existing request abortController?.abort(); abortController = new AbortController(); isStreaming.value = true; error.value = null; usage.value = null; rawLines.value = []; // Start with previous spec if provided, otherwise empty spec const previousSpec = context?.previousSpec as Spec | undefined; let currentSpec: Spec = previousSpec && previousSpec.root ? { ...previousSpec, elements: { ...previousSpec.elements } } : { root: "", elements: {} }; spec.value = currentSpec; try { const response = await fetch(api, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, context, currentSpec, }), signal: abortController.signal, }); if (!response.ok) { // Try to parse JSON error response for better error messages let errorMessage = `HTTP error: ${response.status}`; try { const errorData = await response.json(); if (errorData.message) { errorMessage = errorData.message; } else if (errorData.error) { errorMessage = errorData.error; } } catch { // Ignore JSON parsing errors, use default message } throw new Error(errorMessage); } const reader = response.body?.getReader(); if (!reader) { throw new Error("No response body"); } const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Process complete lines const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; const result = parseLine(trimmed); if (!result) continue; if (result.type === "usage") { usage.value = result.usage; } else { rawLines.value = [...rawLines.value, trimmed]; currentSpec = applyPatch(currentSpec, result.patch); spec.value = { ...currentSpec }; } } } // Process any remaining buffer if (buffer.trim()) { const trimmed = buffer.trim(); const result = parseLine(trimmed); if (result) { if (result.type === "usage") { usage.value = result.usage; } else { rawLines.value = [...rawLines.value, trimmed]; currentSpec = applyPatch(currentSpec, result.patch); spec.value = { ...currentSpec }; } } } onCompleteRef.value?.(currentSpec); } catch (err) { if ((err as Error).name === "AbortError") { return; } const resolvedError = err instanceof Error ? err : new Error(String(err)); error.value = resolvedError; onErrorRef.value?.(resolvedError); } finally { isStreaming.value = false; } }; // Cleanup on unmount onUnmounted(() => { abortController?.abort(); }); return { spec, isStreaming, error, usage, rawLines, send, clear, }; } /** * Convert a flat element list to a Spec. * Input elements use key/parentKey to establish identity and relationships. * Output spec uses the map-based format where key is the map entry key * and parent-child relationships are expressed through children arrays. */ export function flatToTree(elements: FlatElement[]): Spec { const elementMap: Record = {}; let root = ""; // First pass: add all elements to map for (const element of elements) { elementMap[element.key] = { type: element.type, props: element.props, children: [], visible: element.visible, }; } // Second pass: build parent-child relationships for (const element of elements) { if (element.parentKey) { const parent = elementMap[element.parentKey]; if (parent) { if (!parent.children) { parent.children = []; } parent.children.push(element.key); } } else { root = element.key; } } return { root, elements: elementMap }; } // ============================================================================= // useBoundProp — Two-way binding helper for $bindState/$bindItem expressions // ============================================================================= /** * Composable for two-way bound props. Returns `[value, setValue]` where: * * - `value` is the already-resolved prop value (passed through from render props) * - `setValue` writes back to the bound state path (no-op if not bound) * * Designed to work with the `bindings` map that the renderer provides when * a prop uses `{ $bindState: "/path" }` or `{ $bindItem: "field" }`. * * @example * ```ts * import { useBoundProp } from "@json-render/vue"; * * const Input: ComponentFn = ({ props, bindings }) => { * const [value, setValue] = useBoundProp(props.value as string, bindings?.value); * return h("input", { value: value ?? "", onInput: (e) => setValue(e.target.value) }); * }; * ``` */ export function useBoundProp( propValue: T | undefined, bindingPath: string | undefined, ): [T | undefined, (value: T) => void] { const { set } = useStateStore(); return [ propValue, (value: T) => { if (bindingPath) set(bindingPath, value); }, ]; } // ============================================================================= // buildSpecFromParts — Derive Spec from AI SDK data parts // ============================================================================= /** * A single part from the AI SDK's `message.parts` array. This is a minimal * structural type so that library helpers do not depend on the AI SDK. * Fields are optional because different part types carry different data: * - Text parts have `text` * - Data parts have `data` */ export interface DataPart { type: string; text?: string; data?: unknown; } /** * Type guard that validates a data part payload looks like a valid * SpecDataPart before we cast it. */ function isSpecDataPart(data: unknown): data is SpecDataPart { if (typeof data !== "object" || data === null) return false; const obj = data as Record; switch (obj.type) { case "patch": return typeof obj.patch === "object" && obj.patch !== null; case "flat": case "nested": return typeof obj.spec === "object" && obj.spec !== null; default: return false; } } /** * Build a `Spec` by replaying all spec data parts from a message's * parts array. Returns `null` if no spec data parts are present. * * Works with the AI SDK's `UIMessage.parts` array. Picks out parts whose * `type` is `SPEC_DATA_PART_TYPE` and processes them based on the payload's * `type` discriminator: `"patch"`, `"flat"`, or `"nested"`. */ export function buildSpecFromParts(parts: DataPart[]): Spec | null { const spec: Spec = { root: "", elements: {} }; let hasSpec = false; for (const part of parts) { if (part.type === SPEC_DATA_PART_TYPE) { if (!isSpecDataPart(part.data)) continue; const payload = part.data; if (payload.type === "patch") { hasSpec = true; applySpecPatch(spec, payload.patch); } else if (payload.type === "flat") { hasSpec = true; Object.assign(spec, payload.spec); } else if (payload.type === "nested") { hasSpec = true; const flat = nestedToFlat(payload.spec); Object.assign(spec, flat); } } } return hasSpec ? spec : null; } /** * Extract and join all text content from a message's parts array. * * Filters for parts with `type === "text"`, trims each one, and joins them * with double newlines so that text from separate agent steps renders as * distinct paragraphs in markdown. */ export function getTextFromParts(parts: DataPart[]): string { return parts .filter( (p): p is DataPart & { text: string } => p.type === "text" && typeof p.text === "string", ) .map((p) => p.text.trim()) .filter(Boolean) .join("\n\n"); } // ============================================================================= // useJsonRenderMessage — extract spec + text from message parts // ============================================================================= /** * Composable that extracts both the json-render spec and text content from a * message's parts array. Accepts a plain `DataPart[]` or a `Ref` * for reactive use in streaming scenarios. * * Returns `ComputedRef`s that recompute whenever `parts` changes. * * @example * ```ts * import { useJsonRenderMessage } from "@json-render/vue"; * * const { spec, text, hasSpec } = useJsonRenderMessage(message.parts); * ``` */ export function useJsonRenderMessage(parts: DataPart[] | Ref): { spec: ComputedRef; text: ComputedRef; hasSpec: ComputedRef; } { const partsRef = isRef(parts) ? parts : ref(parts); const spec = computed(() => buildSpecFromParts(partsRef.value)); const text = computed(() => getTextFromParts(partsRef.value)); const hasSpec = computed( () => spec.value !== null && Object.keys(spec.value.elements || {}).length > 0, ); return { spec, text, hasSpec }; } // ============================================================================= // useChatUI — Chat + GenUI composable // ============================================================================= /** * A single message in the chat, which may contain text, a rendered UI spec, or both. */ export interface ChatMessage { /** Unique message ID */ id: string; /** Who sent this message */ role: "user" | "assistant"; /** Text content (conversational prose) */ text: string; /** json-render Spec built from JSONL patches (null if no UI was generated) */ spec: Spec | null; } /** * Options for useChatUI */ export interface UseChatUIOptions { /** API endpoint that accepts `{ messages: Array<{ role, content }> }` and returns a text stream */ api: string; /** Callback when streaming completes for a message */ onComplete?: (message: ChatMessage) => void; /** Callback on error */ onError?: (error: Error) => void; } /** * Return type for useChatUI */ export interface UseChatUIReturn { /** All messages in the conversation */ messages: Ref; /** Whether currently streaming an assistant response */ isStreaming: Ref; /** Error from the last request, if any */ error: Ref; /** Send a user message */ send: (text: string) => Promise; /** Clear all messages and reset the conversation */ clear: () => void; } let chatMessageIdCounter = 0; function generateChatId(): string { if ( typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ) { return crypto.randomUUID(); } chatMessageIdCounter += 1; return `msg-${Date.now()}-${chatMessageIdCounter}`; } /** * Composable for chat + GenUI experiences. * * Manages a multi-turn conversation where each assistant message can contain * both conversational text and a json-render UI spec. Sends the full message * history to the API endpoint, reads the streamed response, and separates * text lines from JSONL patch lines using `createMixedStreamParser`. * * @example * ```ts * const { messages, isStreaming, send, clear } = useChatUI({ api: "/api/chat" }); * * await send("Compare weather in NYC and Tokyo"); * ``` */ export function useChatUI({ api, onComplete, onError, }: UseChatUIOptions): UseChatUIReturn { const messages = ref([]); const isStreaming = ref(false); const error = ref(null); const onCompleteRef = ref(onComplete); const onErrorRef = ref(onError); let abortController: AbortController | null = null; const clear = () => { messages.value = []; error.value = null; }; const send = async (text: string): Promise => { if (!text.trim()) return; // Abort any existing request abortController?.abort(); abortController = new AbortController(); const userMessage: ChatMessage = { id: generateChatId(), role: "user", text: text.trim(), spec: null, }; const assistantId = generateChatId(); const assistantMessage: ChatMessage = { id: assistantId, role: "assistant", text: "", spec: null, }; // Append user message and empty assistant placeholder messages.value = [...messages.value, userMessage, assistantMessage]; isStreaming.value = true; error.value = null; // Build messages array for the API (full conversation history + new message). // Vue refs are always current — no stale closure issue unlike React useRef. const historyForApi = [ ...messages.value .filter((m) => m.id !== assistantId) .map((m) => ({ role: m.role, content: m.text })), { role: "user" as const, content: text.trim() }, ]; // Mutable state for accumulating the assistant response let accumulatedText = ""; let currentSpec: Spec = { root: "", elements: {} }; let hasSpec = false; try { const response = await fetch(api, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: historyForApi }), signal: abortController.signal, }); if (!response.ok) { let errorMessage = `HTTP error: ${response.status}`; try { const errorData = await response.json(); if (errorData.message) { errorMessage = errorData.message; } else if (errorData.error) { errorMessage = errorData.error; } } catch { // Ignore JSON parsing errors } throw new Error(errorMessage); } const reader = response.body?.getReader(); if (!reader) { throw new Error("No response body"); } const decoder = new TextDecoder(); // Use createMixedStreamParser to classify lines const parser = createMixedStreamParser({ onPatch(patch) { hasSpec = true; applySpecPatch(currentSpec, patch); messages.value = messages.value.map((m) => m.id === assistantId ? { ...m, spec: { root: currentSpec.root, elements: { ...currentSpec.elements }, ...(currentSpec.state ? { state: { ...currentSpec.state } } : {}), }, } : m, ); }, onText(line) { accumulatedText += (accumulatedText ? "\n" : "") + line; messages.value = messages.value.map((m) => m.id === assistantId ? { ...m, text: accumulatedText } : m, ); }, }); while (true) { const { done, value } = await reader.read(); if (done) break; parser.push(decoder.decode(value, { stream: true })); } parser.flush(); // Build final message for onComplete callback const finalMessage: ChatMessage = { id: assistantId, role: "assistant", text: accumulatedText, spec: hasSpec ? { root: currentSpec.root, elements: { ...currentSpec.elements }, ...(currentSpec.state ? { state: { ...currentSpec.state } } : {}), } : null, }; onCompleteRef.value?.(finalMessage); } catch (err) { if ((err as Error).name === "AbortError") { return; } const resolvedError = err instanceof Error ? err : new Error(String(err)); error.value = resolvedError; // Remove empty assistant message on error messages.value = messages.value.filter( (m) => m.id !== assistantId || m.text.length > 0, ); onErrorRef.value?.(resolvedError); } finally { isStreaming.value = false; } }; // Cleanup on unmount onUnmounted(() => { abortController?.abort(); }); return { messages, isStreaming, error, send, clear, }; } ================================================ FILE: packages/vue/src/index.ts ================================================ // Composables & Providers export { StateProvider, useStateStore, useStateValue, useStateBinding, type StateContextValue, type StateProviderProps, } from "./composables/state"; export { VisibilityProvider, useVisibility, useIsVisible, type VisibilityContextValue, } from "./composables/visibility"; export { ActionProvider, useActions, useAction, ConfirmDialog, type ActionContextValue, type ActionProviderProps, type PendingConfirmation, type ConfirmDialogProps, } from "./composables/actions"; export { ValidationProvider, useOptionalValidation, useValidation, useFieldValidation, type ValidationContextValue, type ValidationProviderProps, type FieldValidationState, } from "./composables/validation"; export { RepeatScopeProvider, useRepeatScope, type RepeatScopeValue, } from "./composables/repeat-scope"; // Schema export { schema, type VueSchema, type VueSpec } from "./schema"; // Core types (re-exported for convenience) export type { Spec, StateStore, ComputedFunction } from "@json-render/core"; export { createStateStore } from "@json-render/core"; // Catalog-aware types for Vue export type { EventHandle, BaseComponentProps, SetState, StateModel, ComponentContext, ComponentFn, Components, ActionFn, Actions, } from "./catalog-types"; // Hooks export { useUIStream, useChatUI, useBoundProp, flatToTree, buildSpecFromParts, getTextFromParts, useJsonRenderMessage, type UseUIStreamOptions, type UseUIStreamReturn, type UseChatUIOptions, type UseChatUIReturn, type ChatMessage, type DataPart, type TokenUsage, } from "./hooks"; // Renderer export { // Registry defineRegistry, type DefineRegistryResult, // createRenderer (higher-level, includes providers) createRenderer, type CreateRendererProps, type ComponentMap, // Low-level Renderer, JSONUIProvider, type ComponentRenderProps, type ComponentRegistry, type RendererProps, type JSONUIProviderProps, } from "./renderer"; ================================================ FILE: packages/vue/src/renderer.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { defineComponent, h, type Component } from "vue"; import { mount } from "@vue/test-utils"; import type { Spec } from "@json-render/core"; import { StateProvider } from "./composables/state"; import { VisibilityProvider } from "./composables/visibility"; import { ActionProvider } from "./composables/actions"; import { ValidationProvider } from "./composables/validation"; import { Renderer, defineRegistry, type ComponentRegistry } from "./renderer"; // --------------------------------------------------------------------------- // Minimal test catalog and registry // --------------------------------------------------------------------------- // defineRegistry ignores the catalog object at runtime — use any cast const catalog = {} as any; const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => { const p = props as Record; return h( "div", { "data-type": "card", "data-title": String(p["title"] ?? "") }, [String(p["title"] ?? ""), children], ); }, Button: ({ props, emit }) => { const p = props as Record; return h( "button", { "data-type": "button", onClick: () => emit("press") }, String(p["label"] ?? ""), ); }, }, }); // --------------------------------------------------------------------------- // Mount helper: wraps in the full provider chain required by ElementRenderer // --------------------------------------------------------------------------- function mountRenderer( spec: Spec | null, reg: ComponentRegistry = registry, extraProps: Record = {}, handlers: Record< string, (params: Record) => Promise > = {}, initialState: Record = {}, ) { return mount(StateProvider as Component, { props: { initialState } as any, slots: { default: () => h(VisibilityProvider as Component, null, { default: () => h(ValidationProvider as Component, null, { default: () => h(ActionProvider as Component, { handlers } as any, { default: () => h(Renderer, { spec, registry: reg, ...extraProps }), }), }), }), }, }); } // --------------------------------------------------------------------------- // defineRegistry tests // --------------------------------------------------------------------------- describe("defineRegistry", () => { it("wraps a render function and returns a Vue component", () => { const result = defineRegistry(catalog, { components: { MyComp: () => h("span", null, "hello"), }, }); expect(result.registry).toBeDefined(); expect(result.registry["MyComp"]).toBeDefined(); }); it("component receives resolved props from the spec element", () => { const spec: Spec = { root: "btn1", elements: { btn1: { type: "Button", props: { label: "Press me" } }, }, }; const wrapper = mountRenderer(spec); expect(wrapper.find("[data-type='button']").text()).toBe("Press me"); }); it("children is passed for container components", () => { const spec: Spec = { root: "card1", elements: { card1: { type: "Card", props: { title: "My Card" }, children: ["btn1"], }, btn1: { type: "Button", props: { label: "Click" } }, }, }; const wrapper = mountRenderer(spec); expect(wrapper.find("[data-type='card']").exists()).toBe(true); expect(wrapper.find("[data-type='button']").exists()).toBe(true); }); it("emit('press') fires the corresponding on.press action", async () => { const handler = vi.fn().mockResolvedValue(undefined); const spec: Spec = { root: "btn1", elements: { btn1: { type: "Button", props: { label: "Click" }, on: { press: { action: "myAction" } }, }, }, }; const wrapper = mountRenderer(spec, registry, {}, { myAction: handler }); await wrapper.find("[data-type='button']").trigger("click"); expect(handler).toHaveBeenCalledOnce(); }); }); // --------------------------------------------------------------------------- // Renderer tests // --------------------------------------------------------------------------- describe("Renderer", () => { it("renders a single-element spec", () => { const spec: Spec = { root: "btn1", elements: { btn1: { type: "Button", props: { label: "Go" } }, }, }; const wrapper = mountRenderer(spec); expect(wrapper.find("[data-type='button']").exists()).toBe(true); expect(wrapper.find("[data-type='button']").text()).toBe("Go"); }); it("renders a nested spec (parent contains a child by key reference)", () => { const spec: Spec = { root: "card1", elements: { card1: { type: "Card", props: { title: "Root" }, children: ["btn1"], }, btn1: { type: "Button", props: { label: "Action" } }, }, }; const wrapper = mountRenderer(spec); const card = wrapper.find("[data-type='card']"); expect(card.exists()).toBe(true); expect(card.find("[data-type='button']").exists()).toBe(true); }); it("uses fallback component for unknown element types", () => { const fallback = defineComponent({ setup() { return () => h("span", { "data-type": "fallback" }, "fallback"); }, }); const spec: Spec = { root: "el1", elements: { el1: { type: "Unknown", props: {} }, }, }; const wrapper = mountRenderer(spec, registry, { fallback }); expect(wrapper.find("[data-type='fallback']").exists()).toBe(true); }); it("passes loading prop through to registered components", () => { let receivedLoading: boolean | undefined; const { registry: testRegistry } = defineRegistry(catalog, { components: { Widget: ({ loading }) => { receivedLoading = loading; return h("div", null, "widget"); }, }, }); const spec: Spec = { root: "w1", elements: { w1: { type: "Widget", props: {} } }, }; mountRenderer(spec, testRegistry, { loading: true }); expect(receivedLoading).toBe(true); }); }); ================================================ FILE: packages/vue/src/renderer.ts ================================================ import { computed, defineComponent, h, inject, onErrorCaptured, provide, ref, watch, type Component, type ComputedRef, type PropType, type VNode, } from "vue"; import type { UIElement, Spec, ActionBinding, Catalog, ComputedFunction, SchemaDefinition, StateStore, } from "@json-render/core"; import { resolveElementProps, resolveBindings, resolveActionParam, evaluateVisibility, getByPath, type PropResolutionContext, } from "@json-render/core"; import type { Components, Actions, ActionFn, SetState, StateModel, CatalogHasActions, EventHandle, } from "./catalog-types"; import { useVisibility } from "./composables/visibility"; import { useActions } from "./composables/actions"; import { useStateStore } from "./composables/state"; import { StateProvider } from "./composables/state"; import { VisibilityProvider } from "./composables/visibility"; import { ActionProvider } from "./composables/actions"; import { ValidationProvider } from "./composables/validation"; import { ConfirmDialog } from "./composables/actions"; import { RepeatScopeProvider, useRepeatScope, } from "./composables/repeat-scope"; /** * Props passed to component renderers */ export interface ComponentRenderProps

> { /** The element being rendered */ element: UIElement; /** Emit a named event */ emit: (event: string) => void; /** Get an event handle with metadata */ on: (event: string) => EventHandle; /** * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions. * Maps prop name → absolute state path for write-back. */ bindings?: Record; /** Whether the parent is loading */ loading?: boolean; } /** * Registry of component renderers (Vue component definitions) */ export type ComponentRegistry = Record; /** * Props for the Renderer component */ export interface RendererProps { spec: Spec | null; registry: ComponentRegistry; loading?: boolean; fallback?: Component; } // --------------------------------------------------------------------------- // FunctionsContext — provides $computed functions to the element tree // --------------------------------------------------------------------------- const EMPTY_FUNCTIONS: Record = {}; const FUNCTIONS_KEY = Symbol("json-render:functions"); const FunctionsProvider = defineComponent({ name: "FunctionsProvider", props: { functions: { type: Object as PropType>, default: undefined, }, }, setup(props, { slots }) { const fns = computed(() => props.functions ?? EMPTY_FUNCTIONS); provide(FUNCTIONS_KEY, fns); return () => slots.default?.(); }, }); function useFunctions(): ComputedRef> { return inject>>( FUNCTIONS_KEY, computed(() => EMPTY_FUNCTIONS), ); } // --------------------------------------------------------------------------- // ElementErrorBoundary — catches rendering errors in individual elements // --------------------------------------------------------------------------- const ElementErrorBoundary = defineComponent({ name: "ElementErrorBoundary", props: { elementType: { type: String, required: true, }, }, setup(props, { slots }) { const hasError = ref(false); onErrorCaptured((error) => { console.error( `[json-render] Rendering error in <${props.elementType}>:`, error, ); hasError.value = true; return false; // prevent propagation }); return () => { if (hasError.value) return null; return slots.default?.(); }; }, }); // --------------------------------------------------------------------------- // resolveAndExecuteBindings — shared helper for emitEvent / watch handlers // --------------------------------------------------------------------------- async function resolveAndExecuteBindings( actionBindings: ActionBinding[], ctx: PropResolutionContext, getSnapshot: () => Record, execute: (binding: ActionBinding) => Promise, cancelled?: () => boolean, ): Promise { for (const b of actionBindings) { if (cancelled?.()) break; if (!b.params) { await execute(b); if (cancelled?.()) break; continue; } const liveCtx: PropResolutionContext = { ...ctx, stateModel: getSnapshot(), }; const resolved: Record = {}; for (const [key, val] of Object.entries(b.params)) { resolved[key] = resolveActionParam(val, liveCtx); } await execute({ ...b, params: resolved }); if (cancelled?.()) break; } } // --------------------------------------------------------------------------- // ElementRenderer — renders a single element from the spec // --------------------------------------------------------------------------- interface ElementRendererInternalProps { element: UIElement; spec: Spec; registry: ComponentRegistry; loading?: boolean; fallback?: Component; } const ElementRenderer = defineComponent({ name: "JsonRenderElement", props: { element: { type: Object as PropType, required: true, }, spec: { type: Object as PropType, required: true, }, registry: { type: Object as PropType, required: true, }, loading: { type: Boolean, default: undefined, }, fallback: { type: Object as PropType, default: undefined, }, }, setup(props: ElementRendererInternalProps) { const repeatScope = useRepeatScope(); const { ctx: visibilityCtx } = useVisibility(); const { execute } = useActions(); const { getSnapshot, state: watchState } = useStateStore(); const functions = useFunctions(); // Build context with repeat scope and $computed functions const fullCtx = computed(() => { const base: PropResolutionContext = repeatScope ? { ...visibilityCtx.value, repeatItem: repeatScope.item, repeatIndex: repeatScope.index, repeatBasePath: repeatScope.basePath, } : { ...visibilityCtx.value }; base.functions = functions.value; return base; }); // Create emit function const emitEvent = async (eventName: string): Promise => { const binding = props.element.on?.[eventName]; if (!binding) return; const actionBindings = Array.isArray(binding) ? binding : [binding]; await resolveAndExecuteBindings( actionBindings, fullCtx.value, getSnapshot, execute, ); }; // Create on() function const onEvent = (eventName: string): EventHandle => { const binding = props.element.on?.[eventName]; if (!binding) { return { emit: () => {}, shouldPreventDefault: false, bound: false }; } const actionBindings = Array.isArray(binding) ? binding : [binding]; const shouldPreventDefault = actionBindings.some((b) => b.preventDefault); return { emit: () => { void emitEvent(eventName); }, shouldPreventDefault, bound: true, }; }; // Watch effect: fire actions when watched state paths change. const watchedValues = computed(() => { const cfg = props.element.watch; if (!cfg) return undefined; const values: Record = {}; for (const path of Object.keys(cfg)) { values[path] = getByPath(watchState.value, path); } return values; }); watch( watchedValues, (current, prev, onCleanup) => { const cfg = props.element.watch; if (!cfg || !current) return; let cancelled = false; onCleanup(() => { cancelled = true; }); const paths = Object.keys(cfg); void (async () => { for (const path of paths) { if (cancelled) break; if (prev && current[path] === prev[path]) continue; const binding = cfg[path]; if (!binding) continue; const bindings = Array.isArray(binding) ? binding : [binding]; await resolveAndExecuteBindings( bindings, fullCtx.value, getSnapshot, execute, () => cancelled, ); } })().catch(console.error); }, { deep: true }, ); return () => { const ctx = fullCtx.value; // Evaluate visibility const isVisible = props.element.visible === undefined ? true : evaluateVisibility(props.element.visible, ctx); if (!isVisible) return null; // Resolve bindings and props const rawProps = props.element.props as Record; const elementBindings = resolveBindings(rawProps, ctx); const resolvedProps = resolveElementProps(rawProps, ctx); const resolvedElement = resolvedProps !== props.element.props ? { ...props.element, props: resolvedProps } : props.element; // Get component from registry const Component = props.registry[resolvedElement.type] ?? props.fallback; if (!Component) { console.warn( `[json-render] No renderer for component type: ${resolvedElement.type}`, ); return null; } // Render children const childrenVNodes: VNode | VNode[] | undefined = resolvedElement.repeat ? h(RepeatChildren, { element: resolvedElement, spec: props.spec, registry: props.registry, loading: props.loading, fallback: props.fallback, }) : (resolvedElement.children ?.map((childKey) => { const childElement = props.spec.elements[childKey]; if (!childElement) { if (!props.loading) { console.warn( `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`, ); } return null; } return h(ElementRenderer, { key: childKey, element: childElement, spec: props.spec, registry: props.registry, loading: props.loading, fallback: props.fallback, }); }) .filter((n): n is VNode => n !== null) ?? undefined); return h( ElementErrorBoundary, { elementType: resolvedElement.type }, { default: () => h( Component, { element: resolvedElement, emit: emitEvent, on: onEvent, bindings: elementBindings, loading: props.loading, }, { default: () => childrenVNodes }, ), }, ); }; }, }); // --------------------------------------------------------------------------- // RepeatChildren — renders child elements once per item in a state array // --------------------------------------------------------------------------- const RepeatChildren = defineComponent({ name: "JsonRenderRepeatChildren", props: { element: { type: Object as PropType, required: true, }, spec: { type: Object as PropType, required: true, }, registry: { type: Object as PropType, required: true, }, loading: { type: Boolean, default: undefined, }, fallback: { type: Object as PropType, default: undefined, }, }, setup(props) { const { state } = useStateStore(); return () => { const repeat = props.element.repeat; if (!repeat?.statePath) return null; const statePath = repeat.statePath; const raw = getByPath(state.value, statePath); const items = Array.isArray(raw) ? (raw as unknown[]) : []; return items.map((itemValue, index) => { const key = repeat.key && typeof itemValue === "object" && itemValue !== null ? String( (itemValue as Record)[repeat.key] ?? index, ) : String(index); return h( RepeatScopeProvider, { key, item: itemValue, index, basePath: `${statePath}/${index}` }, { default: () => props.element.children ?.map((childKey) => { const childElement = props.spec.elements[childKey]; if (!childElement) { if (!props.loading) { console.warn( `[json-render] Missing element "${childKey}" referenced as child of "${props.element.type}" (repeat). This element will not render.`, ); } return null; } return h(ElementRenderer, { key: childKey, element: childElement, spec: props.spec, registry: props.registry, loading: props.loading, fallback: props.fallback, }); }) .filter((n): n is VNode => n !== null) ?? null, }, ); }); }; }, }); // --------------------------------------------------------------------------- // Renderer — main exported component // --------------------------------------------------------------------------- /** * Main renderer component */ export const Renderer = defineComponent({ name: "JsonRenderer", props: { spec: { type: Object as PropType, default: null, }, registry: { type: Object as PropType, required: true, }, loading: { type: Boolean, default: undefined, }, fallback: { type: Object as PropType, default: undefined, }, }, setup(props) { return () => { if (!props.spec?.root) return null; const rootElement = props.spec.elements[props.spec.root]; if (!rootElement) return null; return h(ElementRenderer, { element: rootElement, spec: props.spec, registry: props.registry, loading: props.loading, fallback: props.fallback, }); }; }, }); // --------------------------------------------------------------------------- // ConfirmationDialogManager // --------------------------------------------------------------------------- const ConfirmationDialogManager = defineComponent({ name: "ConfirmationDialogManager", setup() { const { pendingConfirmation, confirm, cancel } = useActions(); return () => { if (!pendingConfirmation?.action.confirm) return null; return h(ConfirmDialog, { confirm: pendingConfirmation.action.confirm, onConfirm: confirm, onCancel: cancel, }); }; }, }); // --------------------------------------------------------------------------- // JSONUIProvider — combined provider for all contexts // --------------------------------------------------------------------------- /** * Props for JSONUIProvider */ export interface JSONUIProviderProps { registry: ComponentRegistry; store?: StateStore; initialState?: Record; handlers?: Record< string, (params: Record) => Promise | unknown >; navigate?: (path: string) => void; validationFunctions?: Record< string, (value: unknown, args?: Record) => boolean >; /** Named functions for `$computed` expressions in props */ functions?: Record; onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; } /** * Combined provider for all JSONUI contexts */ export const JSONUIProvider = defineComponent({ name: "JSONUIProvider", props: { registry: { type: Object as PropType, required: true, }, store: { type: Object as PropType, default: undefined, }, initialState: { type: Object as PropType>, default: undefined, }, handlers: { type: Object as PropType< Record< string, (params: Record) => Promise | unknown > >, default: undefined, }, navigate: { type: Function as PropType<(path: string) => void>, default: undefined, }, validationFunctions: { type: Object as PropType< Record< string, (value: unknown, args?: Record) => boolean > >, default: undefined, }, functions: { type: Object as PropType>, default: undefined, }, onStateChange: { type: Function as PropType< (changes: Array<{ path: string; value: unknown }>) => void >, default: undefined, }, }, setup(props, { slots }) { return () => h( StateProvider, { store: props.store, initialState: props.initialState, onStateChange: props.onStateChange, }, { default: () => h(VisibilityProvider, null, { default: () => h( ValidationProvider, { customFunctions: props.validationFunctions }, { default: () => h( ActionProvider, { handlers: props.handlers, navigate: props.navigate }, { default: () => h( FunctionsProvider, { functions: props.functions }, { default: () => [ slots.default?.(), h(ConfirmationDialogManager), ], }, ), }, ), }, ), }), }, ); }, }); // ============================================================================ // defineRegistry // ============================================================================ /** * Result returned by defineRegistry */ export interface DefineRegistryResult { registry: ComponentRegistry; handlers: ( getSetState: () => SetState | undefined, getState: () => StateModel, ) => Record) => Promise>; executeAction: ( actionName: string, params: Record | undefined, setState: SetState, state?: StateModel, ) => Promise; } type DefineRegistryOptions = { components?: Components; } & (CatalogHasActions extends true ? { actions: Actions } : { actions?: Actions }); type DefineRegistryComponentFn = (ctx: { props: unknown; children?: VNode | VNode[]; emit: (event: string) => void; on: (event: string) => EventHandle; bindings?: Record; loading?: boolean; }) => VNode | VNode[] | null | string; type DefineRegistryActionFn = ( params: Record | undefined, setState: SetState, state: StateModel, ) => Promise; /** * Create a registry from a catalog with components and/or actions. * * @example * ```ts * // Components only * const { registry } = defineRegistry(catalog, { * components: { * Card: ({ props, children }) => h('div', { class: 'card' }, [props.title, children]), * }, * }); * * // Both * const { registry, handlers, executeAction } = defineRegistry(catalog, { * components: { ... }, * actions: { ... }, * }); * ``` */ export function defineRegistry( _catalog: C, options: DefineRegistryOptions, ): DefineRegistryResult { const registry: ComponentRegistry = {}; if (options.components) { for (const [name, componentFn] of Object.entries(options.components)) { registry[name] = defineComponent({ name: `JsonRenderRegistry_${name}`, props: { element: { type: Object as PropType, required: true, }, emit: { type: Function as PropType<(event: string) => void>, required: true, }, on: { type: Function as PropType<(event: string) => EventHandle>, required: true, }, bindings: { type: Object as PropType>, default: undefined, }, loading: { type: Boolean, default: undefined, }, }, setup(registryProps, { slots }) { return () => (componentFn as DefineRegistryComponentFn)({ props: registryProps.element.props, children: slots.default?.(), emit: registryProps.emit, on: registryProps.on, bindings: registryProps.bindings, loading: registryProps.loading, }); }, }); } } const actionMap = options.actions ? (Object.entries(options.actions) as Array< [string, DefineRegistryActionFn] >) : []; const handlers = ( getSetState: () => SetState | undefined, getState: () => StateModel, ): Record) => Promise> => { const result: Record< string, (params: Record) => Promise > = {}; for (const [name, actionFn] of actionMap) { result[name] = async (params) => { const setState = getSetState(); const state = getState(); if (setState) { await actionFn(params, setState, state); } }; } return result; }; const executeAction = async ( actionName: string, params: Record | undefined, setState: SetState, state: StateModel = {}, ): Promise => { const entry = actionMap.find(([name]) => name === actionName); if (entry) { await entry[1](params, setState, state); } else { console.warn(`Unknown action: ${actionName}`); } }; return { registry, handlers, executeAction }; } // ============================================================================ // createRenderer // ============================================================================ /** * Props for renderers created with createRenderer */ export interface CreateRendererProps { spec: Spec | null; store?: StateStore; state?: Record; onAction?: (actionName: string, params?: Record) => void; onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; /** Named functions for `$computed` expressions in props */ functions?: Record; loading?: boolean; fallback?: Component; } /** * Component map type — maps component names to Vue components */ export type ComponentMap< TComponents extends Record, > = { [K in keyof TComponents]: Component; }; /** * Create a renderer from a catalog * * @example * ```typescript * const DashboardRenderer = createRenderer(dashboardCatalog, { * Card: ({ props, children }) => h('div', { class: 'card' }, children), * Metric: ({ props }) => h('span', null, props.value), * }); * * // Usage in template * * ``` */ export function createRenderer< TDef extends SchemaDefinition, TCatalog extends { components: Record }, >( catalog: Catalog, components: ComponentMap, ): Component { const registry: ComponentRegistry = components as unknown as ComponentRegistry; return defineComponent({ name: "CatalogRenderer", props: { spec: { type: Object as PropType, default: null, }, store: { type: Object as PropType, default: undefined, }, state: { type: Object as PropType>, default: undefined, }, onAction: { type: Function as PropType< (actionName: string, params?: Record) => void >, default: undefined, }, onStateChange: { type: Function as PropType< (changes: Array<{ path: string; value: unknown }>) => void >, default: undefined, }, functions: { type: Object as PropType>, default: undefined, }, loading: { type: Boolean, default: undefined, }, fallback: { type: Object as PropType, default: undefined, }, }, setup(rendererProps) { return () => { // Build the action handlers proxy if onAction is provided const actionHandlers = rendererProps.onAction ? new Proxy( {} as Record< string, (params: Record) => void | Promise >, { get: (_target, prop: string) => { return (params: Record) => rendererProps.onAction!(prop, params); }, has: () => true, }, ) : undefined; return h( StateProvider, { store: rendererProps.store, initialState: rendererProps.state, onStateChange: rendererProps.onStateChange, }, { default: () => h(VisibilityProvider, null, { default: () => h(ValidationProvider, null, { default: () => h( ActionProvider, { handlers: actionHandlers }, { default: () => h( FunctionsProvider, { functions: rendererProps.functions }, { default: () => [ h(Renderer, { spec: rendererProps.spec, registry, loading: rendererProps.loading, fallback: rendererProps.fallback, }), h(ConfirmationDialogManager), ], }, ), }, ), }), }), }, ); }; }, }); } ================================================ FILE: packages/vue/src/schema.ts ================================================ import { defineSchema } from "@json-render/core"; /** * The schema for @json-render/vue * * Defines: * - Spec: A flat tree of elements with keys, types, props, and children references * - Catalog: Components with props schemas, and optional actions */ export const schema = defineSchema( (s) => ({ // What the AI-generated SPEC looks like spec: s.object({ /** Root element key */ root: s.string(), /** Flat map of elements by key */ elements: s.record( s.object({ /** Component type from catalog */ type: s.ref("catalog.components"), /** Component props */ props: s.propsOf("catalog.components"), /** Child element keys (flat reference) */ children: s.array(s.string()), /** Visibility condition */ visible: s.any(), }), ), }), // What the CATALOG must provide catalog: s.object({ /** Component definitions */ components: s.map({ /** Zod schema for component props */ props: s.zod(), /** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */ slots: s.array(s.string()), /** Description for AI generation hints */ description: s.string(), /** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */ example: s.any(), }), /** Action definitions (optional) */ actions: s.map({ /** Zod schema for action params */ params: s.zod(), /** Description for AI generation hints */ description: s.string(), }), }), }), { builtInActions: [ { name: "setState", description: "Update a value in the state model at the given statePath. Params: { statePath: string, value: any }", }, { name: "pushState", description: '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.', }, { name: "removeState", description: "Remove an item from an array in state by index. Params: { statePath: string, index: number }", }, { name: "validateForm", description: "Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record }.", }, ], defaultRules: [ // Element integrity "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.", "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.", // Field placement 'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.', 'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.', // State and data "When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).", 'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.', // Design quality "Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.", "For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.", "Always include realistic, professional-looking sample data. For blogs include 3-4 posts with varied titles, authors, dates, categories. For products include names, prices, images. Never leave data empty.", ], }, ); /** * Type for the Vue schema */ export type VueSchema = typeof schema; /** * Infer the spec type from a catalog */ export type VueSpec = typeof schema extends { createCatalog: (catalog: TCatalog) => { _specType: infer S }; } ? S : never; ================================================ FILE: packages/vue/tsconfig.json ================================================ { "extends": "@internal/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/vue/tsup.config.ts ================================================ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts", "src/schema.ts"], format: ["cjs", "esm"], dts: true, sourcemap: true, clean: true, external: ["vue", "@json-render/core"], }); ================================================ FILE: packages/xstate/CHANGELOG.md ================================================ # @json-render/xstate ## 0.14.1 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 ## 0.14.0 ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 ## 0.13.0 ### Patch Changes - Updated dependencies [5b32de8] - @json-render/core@0.13.0 ## 0.12.1 ### Patch Changes - Updated dependencies [54a1ecf] - @json-render/core@0.12.1 ## 0.12.0 ### Patch Changes - Updated dependencies [63c339b] - @json-render/core@0.12.0 ## 0.11.0 ### Patch Changes - Updated dependencies [3f1e71e] - @json-render/core@0.11.0 ## 0.10.0 ### Minor Changes - 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values. ### New: `@json-render/vue` Package Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming. - `defineRegistry` — create type-safe component registries from catalogs - `Renderer` — render specs as Vue component trees - Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider` - Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation` - Streaming: `useUIStream`, `useChatUI` - External store support via `StateStore` interface ### New: `@json-render/xstate` Package XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend. - `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom - Requires `@xstate/store` v3+ ### New: `$computed` Expressions Call registered functions from prop expressions: - `{ "$computed": "functionName", "args": { "key": } }` — calls a named function with resolved args - Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer` - `ComputedFunction` type exported from `@json-render/core` ### New: `$template` Expressions Interpolate state values into strings: - `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values - Missing paths resolve to empty string ### New: State Watchers React to state changes by triggering actions: - `watch` field on elements maps state paths to action bindings - Fires when watched values change (not on initial render) - Supports cascading dependencies (e.g. country → city loading) - `watch` is a top-level field on elements (sibling of type/props/children), not inside props - Spec validator detects and auto-fixes `watch` placed inside props ### New: Cross-Field Validation Functions New built-in validation functions for cross-field comparisons: - `equalTo` — alias for `matches` with clearer semantics - `lessThan` — value must be less than another field (numbers, strings, coerced) - `greaterThan` — value must be greater than another field - `requiredIf` — required only when a condition field is truthy - Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling ### New: `validateForm` Action (React) Built-in action that validates all registered form fields at once: - Runs `validateAll()` synchronously and writes `{ valid, errors }` to state - Default state path: `/formValidation` (configurable via `statePath` param) - Added to React schema's built-in actions list ### Improved: shadcn/ui Validation All form components now support validation: - Checkbox, Radio, Switch — added `checks` and `validateOn` props - Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit) - Shared validation schemas reduce catalog definition duplication ### Improved: React Provider Tree Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access. ### Patch Changes - Updated dependencies [9cef4e9] - @json-render/core@0.10.0 ================================================ FILE: packages/xstate/README.md ================================================ # @json-render/xstate [XState Store](https://stately.ai/docs/xstate-store) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for json-render. ## Installation ```bash npm install @json-render/xstate @json-render/core @json-render/react @xstate/store ``` > [!NOTE] > This adapter requires `@xstate/store` v3+. ## Usage ```ts import { createAtom } from "@xstate/store"; import { xstateStoreStateStore } from "@json-render/xstate"; import { StateProvider } from "@json-render/react"; // 1. Create an atom const uiAtom = createAtom({ count: 0 }); // 2. Create the json-render StateStore adapter const store = xstateStoreStateStore({ atom: uiAtom }); // 3. Use it {/* json-render reads/writes go through @xstate/store */} ``` ## API ### `xstateStoreStateStore(options)` Creates a `StateStore` backed by an `@xstate/store` atom. #### Options | Option | Type | Required | Description | |--------|------|----------|-------------| | `atom` | `Atom` | Yes | An `@xstate/store` atom (from `createAtom`) holding the json-render state model | ================================================ FILE: packages/xstate/package.json ================================================ { "name": "@json-render/xstate", "version": "0.14.1", "license": "Apache-2.0", "description": "XState Store adapter for json-render StateStore", "keywords": [ "json-render", "xstate", "xstate-store", "state-management", "adapter" ], "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", "directory": "packages/xstate" }, "homepage": "https://json-render.dev", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "publishConfig": { "access": "public" }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } }, "files": [ "dist" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "check-types": "tsc --noEmit" }, "dependencies": { "@json-render/core": "workspace:*" }, "peerDependencies": { "@xstate/store": ">=3.0.0" }, "devDependencies": { "@internal/typescript-config": "workspace:*", "tsup": "^8.0.2", "typescript": "^5.4.5", "@xstate/store": "^3.0.0" } } ================================================ FILE: packages/xstate/src/index.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { createAtom } from "@xstate/store"; import { xstateStoreStateStore } from "./index"; function createTestStore(initial: Record = {}) { const atom = createAtom>(initial); const store = xstateStoreStateStore({ atom }); return { atom, store }; } describe("xstateStoreStateStore", () => { it("get/set round-trip", () => { const { store } = createTestStore({ count: 0 }); expect(store.get("/count")).toBe(0); store.set("/count", 42); expect(store.get("/count")).toBe(42); expect(store.getSnapshot().count).toBe(42); }); it("update round-trip with multiple values", () => { const { store } = createTestStore({}); store.update({ "/a": 1, "/b": "hello" }); expect(store.get("/a")).toBe(1); expect(store.get("/b")).toBe("hello"); expect(store.getSnapshot()).toEqual({ a: 1, b: "hello" }); }); it("subscribe fires on set", () => { const { store } = createTestStore({}); const listener = vi.fn(); store.subscribe(listener); store.set("/x", 1); expect(listener).toHaveBeenCalledTimes(1); }); it("subscribe fires on update", () => { const { store } = createTestStore({}); const listener = vi.fn(); store.subscribe(listener); store.update({ "/a": 1, "/b": 2 }); expect(listener).toHaveBeenCalledTimes(1); }); it("unsubscribe stops notifications", () => { const { store } = createTestStore({}); const listener = vi.fn(); const unsub = store.subscribe(listener); store.set("/x", 1); expect(listener).toHaveBeenCalledTimes(1); unsub(); store.set("/x", 2); expect(listener).toHaveBeenCalledTimes(1); }); it("getSnapshot immutability -- previous snapshot is not mutated", () => { const { store } = createTestStore({ user: { name: "Alice", age: 30 }, }); const snap1 = store.getSnapshot(); store.set("/user/name", "Bob"); const snap2 = store.getSnapshot(); expect(snap1.user).toEqual({ name: "Alice", age: 30 }); expect((snap2.user as Record).name).toBe("Bob"); expect(snap1.user).not.toBe(snap2.user); }); it("structural sharing -- untouched branches keep references", () => { const { store } = createTestStore({ a: { x: 1 }, b: { y: 2 }, }); const snap1 = store.getSnapshot(); store.set("/a/x", 99); const snap2 = store.getSnapshot(); expect(snap2.b).toBe(snap1.b); expect(snap2.a).not.toBe(snap1.a); }); it("getServerSnapshot returns same as getSnapshot", () => { const { store } = createTestStore({ x: 1 }); expect(store.getServerSnapshot!()).toBe(store.getSnapshot()); store.set("/x", 2); expect(store.getServerSnapshot!()).toBe(store.getSnapshot()); }); it("set skips update when value is unchanged", () => { const { store } = createTestStore({ x: 1 }); const snap1 = store.getSnapshot(); const listener = vi.fn(); store.subscribe(listener); store.set("/x", 1); expect(listener).not.toHaveBeenCalled(); expect(store.getSnapshot()).toBe(snap1); }); it("update skips update when no values changed", () => { const { store } = createTestStore({ a: 1, b: 2 }); const snap1 = store.getSnapshot(); const listener = vi.fn(); store.subscribe(listener); store.update({ "/a": 1, "/b": 2 }); expect(listener).not.toHaveBeenCalled(); expect(store.getSnapshot()).toBe(snap1); }); it("reads from the shared atom", () => { const atom = createAtom>({ count: 0 }); const store = xstateStoreStateStore({ atom }); atom.set({ count: 99 }); expect(store.get("/count")).toBe(99); expect(store.getSnapshot().count).toBe(99); }); it("subscribe fires on external atom.set", () => { const atom = createAtom>({ count: 0 }); const store = xstateStoreStateStore({ atom }); const listener = vi.fn(); store.subscribe(listener); atom.set({ count: 99 }); expect(listener).toHaveBeenCalledTimes(1); expect(store.get("/count")).toBe(99); }); }); ================================================ FILE: packages/xstate/src/index.ts ================================================ import type { StateModel, StateStore } from "@json-render/core"; import { createStoreAdapter } from "@json-render/core/store-utils"; import type { Atom } from "@xstate/store"; export type { StateStore } from "@json-render/core"; /** * Options for {@link xstateStoreStateStore}. */ export interface XstateStoreStateStoreOptions { /** An `@xstate/store` atom (created with `createAtom`). */ atom: Atom; } /** * Create a {@link StateStore} backed by an `@xstate/store` atom. * * @example * ```ts * import { createAtom } from "@xstate/store"; * import { xstateStoreStateStore } from "@json-render/xstate"; * * const uiAtom = createAtom>({ count: 0 }); * * const store = xstateStoreStateStore({ atom: uiAtom }); * * ... * ``` */ export function xstateStoreStateStore( options: XstateStoreStateStoreOptions, ): StateStore { const { atom } = options; return createStoreAdapter({ getSnapshot: () => atom.get(), setSnapshot: (next) => atom.set(next), subscribe(listener) { const sub = atom.subscribe(() => { listener(); }); return () => sub.unsubscribe(); }, }); } ================================================ FILE: packages/xstate/tsconfig.json ================================================ { "extends": "@internal/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/xstate/tsup.config.ts ================================================ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts"], format: ["cjs", "esm"], dts: true, sourcemap: true, clean: true, external: [ "@json-render/core", "@json-render/core/store-utils", "@xstate/store", ], }); ================================================ FILE: packages/yaml/CHANGELOG.md ================================================ # @json-render/yaml ## 0.14.1 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 ## 0.14.0 ### Minor Changes - a8afd8b: Add YAML wire format package and universal edit modes for surgical spec refinement. ### New: - **`@json-render/yaml`** -- YAML wire format for json-render. Includes streaming YAML parser, `yamlPrompt()` for system prompts, and AI SDK transform (`pipeYamlRender`) as a drop-in alternative to JSONL streaming. Supports four fence types: `yaml-spec`, `yaml-edit`, `yaml-patch`, and `diff`. - **Universal edit modes** in `@json-render/core` -- three strategies for multi-turn spec refinement: `patch` (RFC 6902), `merge` (RFC 7396), and `diff` (unified diff). New `editModes` option on `buildUserPrompt()` and `PromptOptions`. New helpers: `deepMergeSpec()`, `diffToPatches()`, `buildEditUserPrompt()`, `buildEditInstructions()`, `isNonEmptySpec()`. ### Improved: - **Playground** -- format toggle (JSONL / YAML), edit mode picker (patch / merge / diff), and token usage display with prompt caching stats. - **Prompt caching** -- generate API uses Anthropic ephemeral cache control for system prompts. - **CI** -- lint, type-check, and test jobs now run in parallel. ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 ================================================ FILE: packages/yaml/README.md ================================================ # @json-render/yaml YAML wire format for `@json-render/core`. Progressive rendering and surgical edits via streaming YAML. ## Installation ```bash npm install @json-render/yaml @json-render/core yaml ``` ## Key Concepts - **YAML wire format**: Uses `yaml-spec`, `yaml-edit`, `yaml-patch`, and `diff` code fences instead of JSONL - **Streaming parser**: Incrementally parses YAML as it arrives, emitting JSON Patch operations - **Edit modes**: Supports patch (RFC 6902), merge (RFC 7396), and unified diff for surgical edits - **AI SDK transform**: Drop-in `TransformStream` that converts YAML fences into json-render patch data parts ## Quick Start ### Generate a YAML System Prompt ```typescript import { yamlPrompt } from "@json-render/yaml"; import { catalog } from "./catalog"; const systemPrompt = yamlPrompt(catalog, { mode: "standalone", editModes: ["merge"], }); ``` ### Stream YAML Specs (AI SDK) ```typescript import { pipeYamlRender } from "@json-render/yaml"; import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; const stream = createUIMessageStream({ execute: async ({ writer }) => { writer.merge(pipeYamlRender(result.toUIMessageStream())); }, }); return createUIMessageStreamResponse({ stream }); ``` ### Streaming Parser (Low-Level) ```typescript import { createYamlStreamCompiler } from "@json-render/yaml"; const compiler = createYamlStreamCompiler(); // Feed chunks as they arrive const { result, newPatches } = compiler.push("root: main\n"); compiler.push("elements:\n main:\n type: Card\n"); // Flush remaining data const { result: final } = compiler.flush(); ``` ## API Reference ### `yamlPrompt(catalog, options?)` Generate a YAML-format system prompt from any json-render catalog. | Option | Type | Default | Description | |--------|------|---------|-------------| | `system` | `string` | `"You are a UI generator that outputs YAML."` | Custom system message intro | | `mode` | `"standalone" \| "inline"` | `"standalone"` | Output mode | | `customRules` | `string[]` | `[]` | Additional rules | | `editModes` | `EditMode[]` | `["merge"]` | Edit modes to document | ### `createYamlTransform(options?)` Creates a `TransformStream` that converts YAML spec/edit fences in AI SDK stream chunks into json-render patch data parts. | Option | Type | Description | |--------|------|-------------| | `previousSpec` | `Spec` | Seed with a previous spec for multi-turn edit support | ### `pipeYamlRender(stream, options?)` Convenience wrapper that pipes an AI SDK stream through the YAML transform. Drop-in replacement for `pipeJsonRender` from `@json-render/core`. ### `createYamlStreamCompiler(initial?)` Create a streaming YAML compiler that incrementally parses YAML text and emits JSON Patch operations. **Returns** `YamlStreamCompiler` with methods: | Method | Description | |--------|-------------| | `push(chunk)` | Push a chunk of text. Returns `{ result, newPatches }` | | `flush()` | Flush remaining buffer and return final result | | `getResult()` | Get the current compiled result | | `getPatches()` | Get all patches applied so far | | `reset(initial?)` | Reset to initial state | ### Fence Constants Exported constants for fence detection: - `YAML_SPEC_FENCE` — `` ```yaml-spec `` - `YAML_EDIT_FENCE` — `` ```yaml-edit `` - `YAML_PATCH_FENCE` — `` ```yaml-patch `` - `DIFF_FENCE` — `` ```diff `` - `FENCE_CLOSE` — `` ``` `` ### Re-exports from `@json-render/core` - `diffToPatches(oldObj, newObj)` — Generate RFC 6902 JSON Patch from object diff - `deepMergeSpec(base, patch)` — RFC 7396 JSON Merge Patch ================================================ FILE: packages/yaml/package.json ================================================ { "name": "@json-render/yaml", "version": "0.14.1", "license": "Apache-2.0", "description": "YAML wire format for @json-render/core. Progressive rendering and surgical edits via streaming YAML.", "keywords": [ "json", "yaml", "ui", "ai", "generative-ui", "llm", "streaming", "progressive-rendering" ], "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", "directory": "packages/yaml" }, "homepage": "https://json-render.dev", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "publishConfig": { "access": "public" }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } }, "files": [ "dist" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { "@json-render/core": "workspace:*", "diff": "^8.0.3", "yaml": "^2.8.2" }, "devDependencies": { "@internal/typescript-config": "workspace:*", "tsup": "^8.0.2", "typescript": "^5.4.5", "vitest": "^4.0.17", "zod": "^4.3.6" } } ================================================ FILE: packages/yaml/src/diff.test.ts ================================================ import { describe, it, expect } from "vitest"; import { diffToPatches } from "./diff"; describe("diffToPatches", () => { it("returns empty array for identical objects", () => { const obj = { a: 1, b: "hello" }; expect(diffToPatches(obj, obj)).toEqual([]); }); it("detects added keys", () => { const patches = diffToPatches({}, { name: "Alice" }); expect(patches).toEqual([{ op: "add", path: "/name", value: "Alice" }]); }); it("detects removed keys", () => { const patches = diffToPatches({ name: "Alice" }, {}); expect(patches).toEqual([{ op: "remove", path: "/name" }]); }); it("detects changed scalar values", () => { const patches = diffToPatches({ name: "Alice" }, { name: "Bob" }); expect(patches).toEqual([{ op: "replace", path: "/name", value: "Bob" }]); }); it("recurses into nested objects", () => { const oldObj = { user: { name: "Alice", age: 30 } }; const newObj = { user: { name: "Alice", age: 31 } }; const patches = diffToPatches(oldObj, newObj); expect(patches).toEqual([{ op: "replace", path: "/user/age", value: 31 }]); }); it("replaces arrays atomically", () => { const oldObj = { items: ["a", "b"] }; const newObj = { items: ["a", "b", "c"] }; const patches = diffToPatches(oldObj, newObj); expect(patches).toEqual([ { op: "replace", path: "/items", value: ["a", "b", "c"] }, ]); }); it("does not emit patch for identical arrays", () => { const oldObj = { items: [1, 2, 3] }; const newObj = { items: [1, 2, 3] }; expect(diffToPatches(oldObj, newObj)).toEqual([]); }); it("handles type changes (object → scalar)", () => { const oldObj = { data: { nested: true } }; const newObj = { data: "flat" }; const patches = diffToPatches( oldObj as Record, newObj as Record, ); expect(patches).toEqual([{ op: "replace", path: "/data", value: "flat" }]); }); it("handles a complex spec diff", () => { const oldSpec = { root: "main", elements: { main: { type: "Card", props: { title: "Hello" }, children: [] }, }, }; const newSpec = { root: "main", elements: { main: { type: "Card", props: { title: "Hello" }, children: ["child-1"], }, "child-1": { type: "Text", props: { content: "World" }, children: [], }, }, }; const patches = diffToPatches(oldSpec, newSpec); expect(patches).toContainEqual({ op: "replace", path: "/elements/main/children", value: ["child-1"], }); expect(patches).toContainEqual({ op: "add", path: "/elements/child-1", value: { type: "Text", props: { content: "World" }, children: [], }, }); }); it("escapes JSON Pointer tokens (~ and /)", () => { const patches = diffToPatches({}, { "a/b": 1, "c~d": 2 }); expect(patches).toContainEqual({ op: "add", path: "/a~1b", value: 1, }); expect(patches).toContainEqual({ op: "add", path: "/c~0d", value: 2, }); }); }); ================================================ FILE: packages/yaml/src/diff.ts ================================================ export { diffToPatches } from "@json-render/core"; ================================================ FILE: packages/yaml/src/index.ts ================================================ // Diff export { diffToPatches } from "./diff"; // Merge export { deepMergeSpec } from "./merge"; // Streaming YAML compiler export type { YamlStreamCompiler } from "./parser"; export { createYamlStreamCompiler } from "./parser"; // AI SDK transform export { createYamlTransform, pipeYamlRender, YAML_SPEC_FENCE, YAML_EDIT_FENCE, YAML_PATCH_FENCE, DIFF_FENCE, FENCE_CLOSE, } from "./transform"; // Prompt generation export type { YamlPromptOptions } from "./prompt"; export { yamlPrompt } from "./prompt"; ================================================ FILE: packages/yaml/src/merge.test.ts ================================================ import { describe, it, expect } from "vitest"; import { deepMergeSpec } from "./merge"; describe("deepMergeSpec", () => { it("adds new keys", () => { const result = deepMergeSpec({ a: 1 }, { b: 2 }); expect(result).toEqual({ a: 1, b: 2 }); }); it("replaces scalar values", () => { const result = deepMergeSpec({ a: 1 }, { a: 2 }); expect(result).toEqual({ a: 2 }); }); it("deletes keys set to null", () => { const result = deepMergeSpec({ a: 1, b: 2 }, { b: null }); expect(result).toEqual({ a: 1 }); }); it("deep-merges nested objects", () => { const base = { user: { name: "Alice", age: 30 } }; const patch = { user: { age: 31 } }; const result = deepMergeSpec(base, patch); expect(result).toEqual({ user: { name: "Alice", age: 31 } }); }); it("replaces arrays (does not concat)", () => { const base = { items: [1, 2, 3] }; const patch = { items: [4, 5] }; const result = deepMergeSpec(base, patch); expect(result).toEqual({ items: [4, 5] }); }); it("does not mutate base or patch", () => { const base = { a: { b: 1 } }; const patch = { a: { c: 2 } }; const baseCopy = JSON.parse(JSON.stringify(base)); const patchCopy = JSON.parse(JSON.stringify(patch)); deepMergeSpec(base, patch); expect(base).toEqual(baseCopy); expect(patch).toEqual(patchCopy); }); it("handles a spec-like edit merge", () => { const base = { root: "main", elements: { main: { type: "Card", props: { title: "Dashboard" }, children: ["metric-1"], }, "metric-1": { type: "Metric", props: { label: "Revenue", value: "$1M" }, children: [], }, }, }; const patch = { elements: { main: { props: { title: "Updated Dashboard" }, children: ["metric-1", "chart-1"], }, "chart-1": { type: "Chart", props: { data: "revenue" }, children: [], }, }, }; const result = deepMergeSpec(base, patch); expect(result.root).toBe("main"); expect( (result.elements as Record>)["main"], ).toEqual({ type: "Card", props: { title: "Updated Dashboard" }, children: ["metric-1", "chart-1"], }); expect( (result.elements as Record>)["metric-1"], ).toEqual({ type: "Metric", props: { label: "Revenue", value: "$1M" }, children: [], }); expect( (result.elements as Record>)["chart-1"], ).toEqual({ type: "Chart", props: { data: "revenue" }, children: [], }); }); it("deletes an element via null", () => { const base = { elements: { main: { type: "Card" }, old: { type: "Widget" }, }, }; const patch = { elements: { old: null } }; const result = deepMergeSpec(base, patch); expect(result.elements).toEqual({ main: { type: "Card" } }); }); }); ================================================ FILE: packages/yaml/src/merge.ts ================================================ export { deepMergeSpec } from "@json-render/core"; ================================================ FILE: packages/yaml/src/parser.test.ts ================================================ import { describe, it, expect } from "vitest"; import { createYamlStreamCompiler } from "./parser"; describe("createYamlStreamCompiler", () => { it("parses a simple YAML document incrementally", () => { const compiler = createYamlStreamCompiler(); const r1 = compiler.push("root: main\n"); expect(r1.newPatches.length).toBeGreaterThan(0); expect(r1.result).toHaveProperty("root", "main"); }); it("accumulates elements as lines arrive", () => { const compiler = createYamlStreamCompiler(); compiler.push("root: main\n"); compiler.push("elements:\n"); compiler.push(" main:\n"); compiler.push(" type: Card\n"); const { result } = compiler.flush(); expect(result).toHaveProperty("root", "main"); const elements = result.elements as Record>; expect(elements.main).toBeDefined(); expect(elements.main!.type).toBe("Card"); }); it("emits patches only for changes", () => { const compiler = createYamlStreamCompiler(); const r1 = compiler.push("root: main\n"); expect(r1.newPatches).toEqual([ { op: "add", path: "/root", value: "main" }, ]); // Pushing the same content again (no new complete line) should not emit patches const r2 = compiler.push(""); expect(r2.newPatches).toEqual([]); }); it("tracks all patches via getPatches()", () => { const compiler = createYamlStreamCompiler(); compiler.push("a: 1\n"); compiler.push("b: 2\n"); const allPatches = compiler.getPatches(); expect(allPatches.length).toBe(2); expect(allPatches[0]).toEqual({ op: "add", path: "/a", value: 1 }); expect(allPatches[1]).toEqual({ op: "add", path: "/b", value: 2 }); }); it("resets to initial state", () => { const compiler = createYamlStreamCompiler(); compiler.push("root: main\n"); expect(compiler.getResult()).toHaveProperty("root", "main"); compiler.reset(); expect(compiler.getResult()).toEqual({}); expect(compiler.getPatches()).toEqual([]); }); it("resets with initial value and diffs from it", () => { const compiler = createYamlStreamCompiler(); compiler.reset({ root: "existing", elements: {} }); // The YAML includes root, so the initial value is preserved in the diff base const { newPatches } = compiler.push( "root: existing\nelements:\n main:\n type: Card\n", ); const { result } = compiler.flush(); expect(result).toHaveProperty("root", "existing"); expect(result).toHaveProperty("elements"); // Only the new element should be patched, not root (unchanged) expect(newPatches.find((p) => p.path === "/root")).toBeUndefined(); expect(newPatches.find((p) => p.path === "/elements/main")).toBeDefined(); }); it("handles a full spec YAML", () => { const compiler = createYamlStreamCompiler(); const yaml = [ "root: main\n", "elements:\n", " main:\n", " type: Card\n", " props:\n", " title: Dashboard\n", " children:\n", " - metric-1\n", " metric-1:\n", " type: Metric\n", " props:\n", " label: Revenue\n", ' value: "$1.2M"\n', " children: []\n", "state:\n", " revenue: 1200000\n", ]; for (const line of yaml) { compiler.push(line); } const { result } = compiler.flush(); expect(result.root).toBe("main"); expect(result.state).toEqual({ revenue: 1200000 }); const elements = result.elements as Record>; expect(elements.main).toBeDefined(); expect(elements["metric-1"]).toBeDefined(); expect((elements["metric-1"]!.props as Record).label).toBe( "Revenue", ); }); it("does not crash on invalid YAML mid-stream", () => { const compiler = createYamlStreamCompiler(); // Partial YAML that won't parse compiler.push("elements:\n"); compiler.push(" main:\n"); compiler.push(" type: "); // incomplete value — no newline yet // Should not throw, result should still be from last successful parse const r = compiler.push("\n"); expect(r.result).toBeDefined(); }); it("YAML 1.2 does not coerce yes/no/on/off to booleans", () => { const compiler = createYamlStreamCompiler(); compiler.push("active: yes\n"); compiler.push("disabled: no\n"); compiler.push("on_value: on\n"); compiler.push("off_value: off\n"); const { result } = compiler.flush(); // YAML 1.2 (yaml v2 default) treats these as strings, not booleans expect(result.active).toBe("yes"); expect(result.disabled).toBe("no"); expect(result.on_value).toBe("on"); expect(result.off_value).toBe("off"); }); }); ================================================ FILE: packages/yaml/src/parser.ts ================================================ import { parse } from "yaml"; import type { JsonPatch } from "@json-render/core"; import { diffToPatches } from "./diff"; /** * Streaming YAML compiler that incrementally parses YAML text and emits * JSON Patch operations for each change detected. * * Same interface shape as `SpecStreamCompiler` from `@json-render/core`. */ export interface YamlStreamCompiler { /** Push a chunk of text. Returns the current result and any new patches. */ push(chunk: string): { result: T; newPatches: JsonPatch[] }; /** Flush remaining buffer and return the final result. */ flush(): { result: T; newPatches: JsonPatch[] }; /** Get the current compiled result. */ getResult(): T; /** Get all patches that have been applied. */ getPatches(): JsonPatch[]; /** Reset the compiler to initial state. */ reset(initial?: Partial): void; } /** * Create a streaming YAML compiler. * * Incrementally parses YAML text as it arrives and emits JSON Patch * operations by diffing each successful parse against the previous snapshot. * * Uses `yaml.parse()` with YAML 1.2 defaults (the `yaml` v2 default). * YAML 1.2 does not coerce `yes`/`no`/`on`/`off` to booleans. * * @example * ```ts * const compiler = createYamlStreamCompiler(); * compiler.push("root: main\n"); * compiler.push("elements:\n main:\n type: Card\n"); * const { result } = compiler.flush(); * ``` */ export function createYamlStreamCompiler< T extends Record = Record, >(initial?: Partial): YamlStreamCompiler { let accumulated = ""; let snapshot: Record = initial ? { ...initial } : ({} as Record); let result: T = { ...snapshot } as T; const allPatches: JsonPatch[] = []; function tryParse(): { result: T; newPatches: JsonPatch[] } { const newPatches: JsonPatch[] = []; try { const parsed = parse(accumulated); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { const patches = diffToPatches( snapshot, parsed as Record, ); if (patches.length > 0) { snapshot = structuredClone(parsed as Record); result = { ...snapshot } as T; allPatches.push(...patches); newPatches.push(...patches); } } } catch { // Incomplete YAML — wait for more data } return { result, newPatches }; } return { push(chunk: string): { result: T; newPatches: JsonPatch[] } { accumulated += chunk; // Only attempt parse when we have a complete line if (chunk.includes("\n")) { return tryParse(); } return { result, newPatches: [] }; }, flush(): { result: T; newPatches: JsonPatch[] } { return tryParse(); }, getResult(): T { return result; }, getPatches(): JsonPatch[] { return [...allPatches]; }, reset(newInitial?: Partial): void { accumulated = ""; snapshot = newInitial ? { ...newInitial } : ({} as Record); result = { ...snapshot } as T; allPatches.length = 0; }, }; } ================================================ FILE: packages/yaml/src/prompt.test.ts ================================================ import { describe, it, expect } from "vitest"; import { defineSchema, defineCatalog } from "@json-render/core"; import { z } from "zod"; import { yamlPrompt } from "./prompt"; const testSchema = defineSchema( (s) => ({ spec: s.object({ root: s.string(), elements: s.record( s.object({ type: s.ref("catalog.components"), props: s.propsOf("catalog.components"), children: s.array(s.string()), }), ), }), catalog: s.object({ components: s.map({ props: s.zod(), description: s.string(), }), actions: s.map({ description: s.string(), }), }), }), { builtInActions: [{ name: "setState", description: "Set a state value" }], }, ); const testCatalog = defineCatalog(testSchema, { components: { Card: { props: z.object({ title: z.string() }), description: "A card container", }, Text: { props: z.object({ content: z.string() }), description: "Display text", }, }, actions: { refresh: { description: "Refresh data" }, }, }); describe("yamlPrompt", () => { it("generates a prompt string", () => { const prompt = yamlPrompt(testCatalog); expect(typeof prompt).toBe("string"); expect(prompt.length).toBeGreaterThan(0); }); it("includes YAML format instructions", () => { const prompt = yamlPrompt(testCatalog); expect(prompt).toContain("YAML"); expect(prompt).toContain("yaml-spec"); }); it("includes component names from the catalog", () => { const prompt = yamlPrompt(testCatalog); expect(prompt).toContain("Card"); expect(prompt).toContain("Text"); }); it("includes action names", () => { const prompt = yamlPrompt(testCatalog); expect(prompt).toContain("refresh"); expect(prompt).toContain("setState"); }); it("includes yaml-edit instructions", () => { const prompt = yamlPrompt(testCatalog); expect(prompt).toContain("yaml-edit"); expect(prompt).toContain("deep merge"); }); it("includes a YAML example", () => { const prompt = yamlPrompt(testCatalog); expect(prompt).toContain("root: main"); expect(prompt).toContain("elements:"); expect(prompt).toContain("type: Card"); }); it("respects mode: inline", () => { const prompt = yamlPrompt(testCatalog, { mode: "inline" }); expect(prompt).toContain("respond conversationally"); }); it("respects mode: standalone", () => { const prompt = yamlPrompt(testCatalog, { mode: "standalone" }); expect(prompt).toContain("Output ONLY"); }); it("appends custom rules", () => { const prompt = yamlPrompt(testCatalog, { customRules: ["Always use dark theme colors"], }); expect(prompt).toContain("Always use dark theme colors"); }); it("uses custom system message", () => { const prompt = yamlPrompt(testCatalog, { system: "You are a dashboard builder.", }); expect(prompt).toContain("You are a dashboard builder."); }); }); ================================================ FILE: packages/yaml/src/prompt.ts ================================================ import { stringify } from "yaml"; import type { Catalog, EditMode, SchemaDefinition } from "@json-render/core"; import { buildEditInstructions } from "@json-render/core"; interface ZodLike { _def?: Record; } export interface YamlPromptOptions { /** Custom system message intro. */ system?: string; /** * - `"standalone"` (default): LLM outputs only the YAML spec (no prose). * - `"inline"`: LLM responds conversationally, then wraps YAML in a fence. */ mode?: "standalone" | "inline"; /** Additional rules appended to the RULES section. */ customRules?: string[]; /** Edit modes to document. Default: `["merge"]` (yaml-edit). */ editModes?: EditMode[]; } interface CatalogComponentDef { props?: ZodLike; description?: string; slots?: string[]; events?: string[]; example?: Record; } // ── Zod introspection (local, minimal) ────────────────────────────────────── function getZodTypeName(schema: ZodLike): string { if (!schema?._def) return ""; const def = schema._def; return ( (def.typeName as string | undefined) ?? (typeof def.type === "string" ? (def.type as string) : "") ?? "" ); } function formatZodType(schema: ZodLike): string { if (!schema?._def) return "unknown"; const def = schema._def; const typeName = getZodTypeName(schema); switch (typeName) { case "ZodString": case "string": return "string"; case "ZodNumber": case "number": return "number"; case "ZodBoolean": case "boolean": return "boolean"; case "ZodLiteral": case "literal": return JSON.stringify(def.value); case "ZodEnum": case "enum": { let values: string[]; if (Array.isArray(def.values)) { values = def.values as string[]; } else if (def.entries && typeof def.entries === "object") { values = Object.values(def.entries as Record); } else { return "enum"; } return values.map((v) => `"${v}"`).join(" | "); } case "ZodArray": case "array": { const inner = ( typeof def.element === "object" ? def.element : typeof def.type === "object" ? def.type : undefined ) as ZodLike | undefined; return inner ? `Array<${formatZodType(inner)}>` : "Array"; } case "ZodObject": case "object": { const shape = typeof def.shape === "function" ? (def.shape as () => Record)() : (def.shape as Record); if (!shape) return "object"; const props = Object.entries(shape) .map(([key, value]) => { const innerTypeName = getZodTypeName(value); const isOptional = innerTypeName === "ZodOptional" || innerTypeName === "ZodNullable" || innerTypeName === "optional" || innerTypeName === "nullable"; return `${key}${isOptional ? "?" : ""}: ${formatZodType(value)}`; }) .join(", "); return `{ ${props} }`; } case "ZodOptional": case "optional": case "ZodNullable": case "nullable": { const inner = (def.innerType as ZodLike) ?? (def.wrapped as ZodLike); return inner ? formatZodType(inner) : "unknown"; } case "ZodUnion": case "union": { const options = def.options as ZodLike[] | undefined; return options ? options.map((opt) => formatZodType(opt)).join(" | ") : "unknown"; } default: return "unknown"; } } function getExampleProps(def: CatalogComponentDef): Record { if (def.example && Object.keys(def.example).length > 0) return def.example; if (!def.props?._def) return {}; const zodDef = def.props._def; const typeName = getZodTypeName(def.props); if (typeName !== "ZodObject" && typeName !== "object") return {}; const shape = typeof zodDef.shape === "function" ? (zodDef.shape as () => Record)() : (zodDef.shape as Record); if (!shape) return {}; const result: Record = {}; for (const [key, value] of Object.entries(shape)) { const inner = getZodTypeName(value); if ( inner === "ZodOptional" || inner === "optional" || inner === "ZodNullable" || inner === "nullable" ) continue; result[key] = exampleValue(value); } return result; } function exampleValue(schema: ZodLike): unknown { if (!schema?._def) return "..."; const def = schema._def; const t = getZodTypeName(schema); switch (t) { case "ZodString": case "string": return "example"; case "ZodNumber": case "number": return 0; case "ZodBoolean": case "boolean": return true; case "ZodLiteral": case "literal": return def.value; case "ZodEnum": case "enum": { if (Array.isArray(def.values) && (def.values as unknown[]).length > 0) return (def.values as unknown[])[0]; if (def.entries && typeof def.entries === "object") { const vals = Object.values(def.entries as Record); return vals.length > 0 ? vals[0] : "example"; } return "example"; } case "ZodOptional": case "optional": case "ZodNullable": case "nullable": case "ZodDefault": case "default": { const inner = (def.innerType as ZodLike) ?? (def.wrapped as ZodLike); return inner ? exampleValue(inner) : null; } case "ZodArray": case "array": return []; case "ZodObject": case "object": return getExampleProps({ props: schema } as CatalogComponentDef); case "ZodUnion": case "union": { const options = def.options as ZodLike[] | undefined; return options && options.length > 0 ? exampleValue(options[0]!) : "..."; } default: return "..."; } } // ── YAML helper ── /** Render a value as an indented YAML string (2-space indent). */ function toYaml(value: unknown): string { return stringify(value, { indent: 2 }).trimEnd(); } // ── Prompt generation ── /** * Generate a YAML-format system prompt from any json-render catalog. * * Works with catalogs from any renderer (`@json-render/react`, * `@json-render/vue`, etc.) — it only reads the catalog metadata. * * @example * ```ts * import { yamlPrompt } from "@json-render/yaml"; * const systemPrompt = yamlPrompt(catalog, { mode: "inline" }); * ``` */ export function yamlPrompt( catalog: Catalog, options?: YamlPromptOptions, ): string { const { system = "You are a UI generator that outputs YAML.", mode = "standalone", customRules = [], editModes = ["merge"], } = options ?? {}; const lines: string[] = []; lines.push(system); lines.push(""); // ── Output format ── if (mode === "inline") { lines.push("OUTPUT FORMAT (text + YAML):"); lines.push( "You respond conversationally. When generating UI, first write a brief explanation (1-3 sentences), then output the YAML spec wrapped in a ```yaml-spec code fence.", ); lines.push( "If the user's message does not require a UI (e.g. a greeting or clarifying question), respond with text only — no YAML.", ); } else { lines.push("OUTPUT FORMAT (YAML):"); lines.push( "Output a YAML document that describes a UI tree. Wrap it in a ```yaml-spec code fence.", ); } lines.push(""); lines.push( "The YAML document has three top-level keys: root, elements, and state (optional).", ); lines.push( "Stream progressively — output elements one at a time so the UI fills in as you write.", ); lines.push(""); // ── Example ── const allComponents = (catalog.data as Record).components as | Record | undefined; const cn = catalog.componentNames; const comp1 = cn[0] || "Component"; const comp2 = cn.length > 1 ? cn[1]! : comp1; const comp1Def = allComponents?.[comp1]; const comp2Def = allComponents?.[comp2]; const comp1Props = comp1Def ? getExampleProps(comp1Def) : {}; const comp2Props = comp2Def ? getExampleProps(comp2Def) : {}; const exampleSpec = { root: "main", elements: { main: { type: comp1, props: comp1Props, children: ["child-1", "list"], }, "child-1": { type: comp2, props: comp2Props, children: [], }, list: { type: comp1, props: comp1Props, repeat: { statePath: "/items", key: "id" }, children: ["item"], }, item: { type: comp2, props: { ...comp2Props }, children: [], }, }, state: { items: [ { id: "1", title: "First Item" }, { id: "2", title: "Second Item" }, ], }, }; lines.push("Example:"); lines.push(""); lines.push("```yaml-spec"); lines.push(toYaml(exampleSpec)); lines.push("```"); lines.push(""); // ── Edit modes (dynamic based on config) ── lines.push(buildEditInstructions({ modes: editModes }, "yaml")); // ── Initial state ── lines.push("INITIAL STATE:"); lines.push( "The spec includes a top-level `state` key to seed the state model. Components using $state, $bindState, $bindItem, $item, or $index read from this state.", ); lines.push( "CRITICAL: You MUST include state whenever your UI displays data via these expressions or uses repeat to iterate over arrays.", ); lines.push( "Include realistic sample data. For lists: 3-5 items with relevant fields. Never leave arrays empty.", ); lines.push( 'When content comes from the state model, use { "$state": "/some/path" } dynamic props instead of hardcoding values.', ); lines.push( 'State paths use RFC 6901 JSON Pointer syntax (e.g. "/todos/0/title"). Do NOT use dot notation.', ); lines.push(""); // ── Dynamic lists ── lines.push("DYNAMIC LISTS (repeat field):"); lines.push( "Any element can have a top-level `repeat` field to render its children once per item in a state array.", ); lines.push("Example:"); lines.push(""); lines.push( toYaml({ list: { type: comp1, props: comp1Props, repeat: { statePath: "/todos", key: "id" }, children: ["todo-item"], }, }), ); lines.push(""); lines.push( 'Inside repeated children, use { "$item": "field" } for item data and { "$index": true } for the array index.', ); lines.push( "ALWAYS use repeat for lists backed by state arrays. NEVER hardcode individual elements per item.", ); lines.push( "IMPORTANT: `repeat` is a top-level field on the element (sibling of type/props/children), NOT inside props.", ); lines.push(""); // ── Array state actions ── lines.push("ARRAY STATE ACTIONS:"); lines.push( 'Use "pushState" to append items to arrays. Use "removeState" to remove items by index.', ); lines.push('Use "$id" inside pushState values to auto-generate a unique ID.'); lines.push(""); // ── Components ── if (allComponents) { lines.push(`AVAILABLE COMPONENTS (${catalog.componentNames.length}):`); lines.push(""); for (const [name, def] of Object.entries(allComponents)) { const propsStr = def.props ? formatZodType(def.props) : "{}"; const hasChildren = def.slots && def.slots.length > 0; const childrenStr = hasChildren ? " [accepts children]" : ""; const eventsStr = def.events && def.events.length > 0 ? ` [events: ${def.events.join(", ")}]` : ""; const descStr = def.description ? ` - ${def.description}` : ""; lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`); } lines.push(""); } // ── Actions ── const actions = (catalog.data as Record).actions as | Record | undefined; const builtInActions = catalog.schema.builtInActions ?? []; const hasCustomActions = actions && catalog.actionNames.length > 0; const hasBuiltInActions = builtInActions.length > 0; if (hasCustomActions || hasBuiltInActions) { lines.push("AVAILABLE ACTIONS:"); lines.push(""); for (const action of builtInActions) { lines.push(`- ${action.name}: ${action.description} [built-in]`); } if (hasCustomActions) { for (const [name, def] of Object.entries(actions)) { lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`); } } lines.push(""); } // ── Events ── lines.push("EVENTS (the `on` field):"); lines.push( "Elements can have an optional `on` field to bind events to actions. It is a top-level field (sibling of type/props/children), NOT inside props.", ); lines.push("Example:"); lines.push(""); lines.push( toYaml({ "save-btn": { type: comp1, props: comp1Props, on: { press: { action: "setState", params: { statePath: "/saved", value: true }, }, }, children: [], }, }), ); lines.push(""); lines.push( 'Action params can use dynamic references: { "$state": "/statePath" }.', ); lines.push( "IMPORTANT: Do NOT put action/actionParams inside props. Always use the `on` field.", ); lines.push(""); // ── Visibility ── lines.push("VISIBILITY CONDITIONS:"); lines.push( "Elements can have an optional `visible` field to conditionally show/hide based on state. It is a top-level field (sibling of type/props/children).", ); lines.push("Conditions:"); lines.push('- { "$state": "/path" } — visible when truthy'); lines.push('- { "$state": "/path", "not": true } — visible when falsy'); lines.push('- { "$state": "/path", "eq": "value" } — visible when equal'); lines.push( '- { "$state": "/path", "neq": "value" } — visible when not equal', ); lines.push( '- { "$state": "/path", "gt": N } / gte / lt / lte — numeric comparisons', ); lines.push("- Use ONE operator per condition. Do not combine multiple."); lines.push('- Add "not": true to any condition to invert it.'); lines.push("- [cond, cond] — implicit AND (all must be true)"); lines.push('- { "$and": [...] } — explicit AND'); lines.push('- { "$or": [...] } — at least one must be true'); lines.push("- true / false — always visible/hidden"); lines.push(""); // ── Dynamic props ── lines.push("DYNAMIC PROPS:"); lines.push("Any prop value can be a dynamic expression:"); lines.push( '1. Read-only: { "$state": "/path" } — resolves to the value at that state path.', ); lines.push( '2. Two-way binding: { "$bindState": "/path" } — read + write. Use on form inputs.', ); lines.push( ' Inside repeat scopes: { "$bindItem": "field" } for item-level binding.', ); lines.push( '3. Conditional: { "$cond": , "$then": , "$else": }', ); lines.push( '4. Template: { "$template": "Hello, ${/name}!" } — interpolates state references.', ); lines.push(""); // ── $computed (only if catalog has functions) ── const catalogFunctions = (catalog.data as Record).functions; if (catalogFunctions && Object.keys(catalogFunctions).length > 0) { lines.push( '5. Computed: { "$computed": "", "args": { "key": } }', ); lines.push(" Available functions:"); for (const name of Object.keys( catalogFunctions as Record, )) { lines.push(` - ${name}`); } lines.push(""); } // ── Validation (only if components have checks) ── const hasChecks = allComponents ? Object.values(allComponents).some((def) => { if (!def.props) return false; return formatZodType(def.props).includes("checks"); }) : false; if (hasChecks) { lines.push("VALIDATION:"); lines.push( "Form components with a `checks` prop support client-side validation.", ); lines.push( "Built-in types: required, email, minLength, maxLength, pattern, min, max, numeric, url, matches, equalTo, lessThan, greaterThan, requiredIf.", ); lines.push( "IMPORTANT: Components with checks must also use $bindState or $bindItem for two-way binding.", ); lines.push(""); } // ── State watchers ── if (hasCustomActions || hasBuiltInActions) { lines.push("STATE WATCHERS:"); lines.push( "Elements can have an optional `watch` field to trigger actions when state changes. Top-level field, NOT inside props.", ); lines.push( "Maps state paths to action bindings. Fires when the watched value changes (not on initial render).", ); lines.push(""); } // ── Rules ── lines.push("RULES:"); const baseRules = mode === "inline" ? [ "When generating UI, wrap the YAML in a ```yaml-spec code fence", "Write a brief conversational response before the YAML", "When editing existing UI, use a ```yaml-edit fence with only the changed parts", "The document must have: root (string), elements (map of elements), and optionally state", "Each element must have: type, props, children (list of child keys)", "ONLY use components listed above", "Use unique, descriptive keys for elements (e.g. 'header', 'metric-1', 'chart-revenue')", "Include state whenever using $state, $bindState, $bindItem, $item, $index, or repeat", ] : [ "Output ONLY the YAML spec inside a ```yaml-spec fence — no prose, no extra markdown", "When editing existing UI, use a ```yaml-edit fence with only the changed parts", "The document must have: root (string), elements (map of elements), and optionally state", "Each element must have: type, props, children (list of child keys)", "ONLY use components listed above", "Use unique, descriptive keys for elements (e.g. 'header', 'metric-1', 'chart-revenue')", "Include state whenever using $state, $bindState, $bindItem, $item, $index, or repeat", ]; const schemaRules = catalog.schema.defaultRules ?? []; const allRules = [...baseRules, ...schemaRules, ...customRules]; allRules.forEach((rule, i) => { lines.push(`${i + 1}. ${rule}`); }); return lines.join("\n"); } ================================================ FILE: packages/yaml/src/transform.test.ts ================================================ import { describe, it, expect } from "vitest"; import { SPEC_DATA_PART_TYPE, type StreamChunk } from "@json-render/core"; import { createYamlTransform } from "./transform"; /** Helper: feed text chunks through the transform and collect output. */ async function runTransform( lines: string[], options?: Parameters[0], ): Promise { const transform = createYamlTransform(options); const output: StreamChunk[] = []; // Build input chunks const inputChunks: StreamChunk[] = [ { type: "text-start", id: "1" }, ...lines.map((l) => ({ type: "text-delta" as const, id: "1", delta: l })), { type: "text-end", id: "1" }, ]; // Create a readable from the input chunks and pipe through const input = new ReadableStream({ start(controller) { for (const chunk of inputChunks) { controller.enqueue(chunk); } controller.close(); }, }); const outputStream = input.pipeThrough(transform); const reader = outputStream.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; output.push(value); } return output; } function extractPatches(output: StreamChunk[]) { return output .filter((c) => c.type === SPEC_DATA_PART_TYPE) .map((c) => (c as { data: { type: string; patch: unknown } }).data.patch); } function extractText(output: StreamChunk[]) { return output .filter((c) => c.type === "text-delta") .map((c) => (c as { delta: string }).delta) .join(""); } describe("createYamlTransform", () => { it("passes through plain text outside fences", async () => { const output = await runTransform(["Hello world\n", "How are you?\n"]); const text = extractText(output); expect(text).toContain("Hello world"); expect(text).toContain("How are you?"); }); it("parses yaml-spec fence into patches", async () => { const output = await runTransform([ "Here is your UI:\n", "```yaml-spec\n", "root: main\n", "elements:\n", " main:\n", " type: Card\n", " props:\n", " title: Dashboard\n", " children: []\n", "```\n", ]); const patches = extractPatches(output); expect(patches.length).toBeGreaterThan(0); // Should have patched the root const rootPatch = patches.find( (p: any) => p.path === "/root" && p.value === "main", ); expect(rootPatch).toBeDefined(); // Text before the fence should pass through const text = extractText(output); expect(text).toContain("Here is your UI:"); }); it("parses yaml-edit fence with merge semantics", async () => { const previousSpec = { root: "main", elements: { main: { type: "Card", props: { title: "Old Title" }, children: [], }, }, }; // Only send a yaml-edit block — previousSpec is the base const output = await runTransform( [ "```yaml-edit\n", "elements:\n", " main:\n", " props:\n", " title: New Title\n", "```\n", ], { previousSpec: previousSpec as any }, ); const patches = extractPatches(output); // Should have at least one patch expect(patches.length).toBeGreaterThan(0); // Should include a patch that updates the title — could be at // the leaf level or replacing the whole props object const hasTitleUpdate = patches.some( (p: any) => (p.path === "/elements/main/props/title" && p.value === "New Title") || (p.path === "/elements/main/props" && (p.value as any)?.title === "New Title"), ); expect(hasTitleUpdate).toBe(true); }); it("swallows fence delimiters (not emitted as text)", async () => { const output = await runTransform([ "```yaml-spec\n", "root: main\n", "```\n", ]); const text = extractText(output); expect(text).not.toContain("```yaml-spec"); expect(text).not.toContain("```"); }); it("handles non-text chunks by passing them through", async () => { const transform = createYamlTransform(); const input = new ReadableStream({ start(controller) { controller.enqueue({ type: "tool-call", id: "t1", name: "getWeather" }); controller.close(); }, }); const reader = input.pipeThrough(transform).getReader(); const { value } = await reader.read(); expect(value).toEqual({ type: "tool-call", id: "t1", name: "getWeather", }); }); }); ================================================ FILE: packages/yaml/src/transform.ts ================================================ import { parse, stringify } from "yaml"; import { applyPatch as applyUnifiedDiff } from "diff"; import { SPEC_DATA_PART_TYPE, deepMergeSpec, diffToPatches, type JsonPatch, type Spec, type StreamChunk, } from "@json-render/core"; import { createYamlStreamCompiler } from "./parser"; export const YAML_SPEC_FENCE = "```yaml-spec"; export const YAML_EDIT_FENCE = "```yaml-edit"; export const YAML_PATCH_FENCE = "```yaml-patch"; export const DIFF_FENCE = "```diff"; export const FENCE_CLOSE = "```"; export interface YamlTransformOptions { /** Seed with a previous spec for multi-turn edit support. */ previousSpec?: Spec; } /** * Creates a `TransformStream` that intercepts AI SDK UI message stream chunks * and converts YAML spec/edit blocks into json-render patch data parts. * * Two fence types are recognised: * * 1. **`\`\`\`yaml-spec`** — Full YAML spec. Parsed progressively, emitting * patches as each new property is detected. * 2. **`\`\`\`yaml-edit`** — Partial YAML. Deep-merged with the current spec, * then diffed to produce patches. Only changed keys need to be included. * * Non-fenced text passes through unchanged as `text-delta` chunks, matching * the behaviour of `createJsonRenderTransform` from `@json-render/core`. */ export function createYamlTransform( options?: YamlTransformOptions, ): TransformStream { let currentTextId = ""; let inTextBlock = false; let textIdCounter = 0; let lineBuffer = ""; let buffering = false; // Fence state let fenceMode: "yaml-spec" | "yaml-edit" | "yaml-patch" | "diff" | null = null; let yamlAccumulated = ""; let diffAccumulated = ""; // Streaming compiler for yaml-spec progressive rendering let compiler = createYamlStreamCompiler>(); // The "current spec" — built up during yaml-spec, used as base for yaml-edit let currentSpec: Record = options?.previousSpec ? structuredClone( options.previousSpec as unknown as Record, ) : {}; // ── Text block helpers (same pattern as createJsonRenderTransform) ── function closeTextBlock( controller: TransformStreamDefaultController, ) { if (inTextBlock) { controller.enqueue({ type: "text-end", id: currentTextId }); inTextBlock = false; } } function ensureTextBlock( controller: TransformStreamDefaultController, ) { if (!inTextBlock) { textIdCounter++; currentTextId = String(textIdCounter); controller.enqueue({ type: "text-start", id: currentTextId }); inTextBlock = true; } } function emitTextDelta( delta: string, controller: TransformStreamDefaultController, ) { ensureTextBlock(controller); controller.enqueue({ type: "text-delta", id: currentTextId, delta }); } function emitPatch( patch: JsonPatch, controller: TransformStreamDefaultController, ) { closeTextBlock(controller); controller.enqueue({ type: SPEC_DATA_PART_TYPE, data: { type: "patch", patch }, }); } function emitPatches( patches: JsonPatch[], controller: TransformStreamDefaultController, ) { for (const patch of patches) { emitPatch(patch, controller); } } // ── YAML fence processing ── /** * Feed a line of YAML to the streaming compiler (yaml-spec mode). * Emits patches for any newly detected properties. */ function feedYamlSpec( line: string, controller: TransformStreamDefaultController, ) { yamlAccumulated += line + "\n"; const { newPatches } = compiler.push(line + "\n"); if (newPatches.length > 0) { emitPatches(newPatches, controller); } } /** * Feed a line of YAML for edit mode. We accumulate all lines and process * on fence close since partial edits may not parse until complete. */ function feedYamlEdit(line: string) { yamlAccumulated += line + "\n"; } /** * Finalise a yaml-edit block: parse the accumulated YAML, deep-merge * with the current spec, diff, and emit patches. */ function finaliseYamlEdit( controller: TransformStreamDefaultController, ) { try { const editObj = parse(yamlAccumulated); if (editObj && typeof editObj === "object" && !Array.isArray(editObj)) { const merged = deepMergeSpec( currentSpec, editObj as Record, ); const patches = diffToPatches(currentSpec, merged); if (patches.length > 0) { currentSpec = merged; emitPatches(patches, controller); } } } catch { // Invalid YAML edit block — silently drop } } /** * Finalise a yaml-spec block: flush the compiler for any remaining * partial data and update currentSpec. */ function finaliseYamlSpec( controller: TransformStreamDefaultController, ) { const { newPatches } = compiler.flush(); if (newPatches.length > 0) { emitPatches(newPatches, controller); } currentSpec = structuredClone( compiler.getResult() as Record, ); } /** * Finalise a yaml-patch block: parse each accumulated line as an * RFC 6902 JSON Patch operation and emit directly. */ function finaliseYamlPatch( controller: TransformStreamDefaultController, ) { for (const line of yamlAccumulated.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; try { const patch = JSON.parse(trimmed) as JsonPatch; if (patch.op) { emitPatch(patch, controller); // Update currentSpec for subsequent edits if (patch.op === "add" || patch.op === "replace") { const parts = patch.path.split("/").filter(Boolean); let target: Record = currentSpec; for (let i = 0; i < parts.length - 1; i++) { const key = parts[i]!; if (typeof target[key] !== "object" || target[key] === null) { target[key] = {}; } target = target[key] as Record; } const lastKey = parts[parts.length - 1]; if (lastKey) target[lastKey] = patch.value; } else if (patch.op === "remove") { const parts = patch.path.split("/").filter(Boolean); let target: Record = currentSpec; for (let i = 0; i < parts.length - 1; i++) { const key = parts[i]!; if (typeof target[key] !== "object" || target[key] === null) break; target = target[key] as Record; } const lastKey = parts[parts.length - 1]; if (lastKey) delete target[lastKey]; } } } catch { // Skip invalid JSON lines } } } /** * Finalise a diff block: apply the unified diff to the YAML-serialized * current spec, reparse, diff against current, and emit patches. */ function finaliseDiff( controller: TransformStreamDefaultController, ) { try { const currentYaml = stringify(currentSpec, { indent: 2 }); const patched = applyUnifiedDiff(currentYaml, diffAccumulated); if (patched === false) return; const newSpec = parse(patched); if (newSpec && typeof newSpec === "object" && !Array.isArray(newSpec)) { const patches = diffToPatches( currentSpec, newSpec as Record, ); if (patches.length > 0) { currentSpec = newSpec as Record; emitPatches(patches, controller); } } } catch { // Diff apply or reparse failed } } // ── Line processing ── function processCompleteLine( line: string, controller: TransformStreamDefaultController, ) { const trimmed = line.trim(); // Fence open detection if (fenceMode === null) { if (trimmed.startsWith(YAML_SPEC_FENCE)) { fenceMode = "yaml-spec"; yamlAccumulated = ""; compiler.reset(currentSpec); return; } if (trimmed.startsWith(YAML_EDIT_FENCE)) { fenceMode = "yaml-edit"; yamlAccumulated = ""; return; } if (trimmed.startsWith(YAML_PATCH_FENCE)) { fenceMode = "yaml-patch"; yamlAccumulated = ""; return; } if (trimmed.startsWith(DIFF_FENCE)) { fenceMode = "diff"; diffAccumulated = ""; return; } } // Fence close detection if (fenceMode !== null && trimmed === FENCE_CLOSE) { if (fenceMode === "yaml-spec") { finaliseYamlSpec(controller); } else if (fenceMode === "yaml-edit") { finaliseYamlEdit(controller); } else if (fenceMode === "yaml-patch") { finaliseYamlPatch(controller); } else if (fenceMode === "diff") { finaliseDiff(controller); } fenceMode = null; return; } // Inside a fence if (fenceMode === "yaml-spec") { feedYamlSpec(line, controller); return; } if (fenceMode === "yaml-edit") { feedYamlEdit(line); return; } if (fenceMode === "yaml-patch") { yamlAccumulated += line + "\n"; return; } if (fenceMode === "diff") { diffAccumulated += line + "\n"; return; } // Outside fence — pass through as text if (!trimmed) { emitTextDelta("\n", controller); return; } emitTextDelta(line + "\n", controller); } function flushBuffer( controller: TransformStreamDefaultController, ) { if (!lineBuffer) return; if (fenceMode !== null) { processCompleteLine(lineBuffer, controller); } else { emitTextDelta(lineBuffer, controller); } lineBuffer = ""; buffering = false; } // ── TransformStream ── return new TransformStream({ transform(chunk, controller) { switch (chunk.type) { case "text-start": { const id = (chunk as { id: string }).id; const idNum = parseInt(id, 10); if (!isNaN(idNum) && idNum >= textIdCounter) { textIdCounter = idNum; } currentTextId = id; inTextBlock = true; controller.enqueue(chunk); break; } case "text-delta": { const delta = chunk as { id: string; delta: string }; const text = delta.delta; for (let i = 0; i < text.length; i++) { const ch = text.charAt(i); if (ch === "\n") { if (buffering) { processCompleteLine(lineBuffer, controller); lineBuffer = ""; buffering = false; } else if (fenceMode === null) { emitTextDelta("\n", controller); } } else if (lineBuffer.length === 0 && !buffering) { // Inside a fence, buffer everything. Outside, only buffer // potential fence-open lines (start with backtick). if (fenceMode !== null || ch === "`") { buffering = true; lineBuffer += ch; } else { emitTextDelta(ch, controller); } } else if (buffering) { lineBuffer += ch; } else { emitTextDelta(ch, controller); } } break; } case "text-end": { flushBuffer(controller); if (inTextBlock) { controller.enqueue({ type: "text-end", id: currentTextId }); inTextBlock = false; } break; } default: { controller.enqueue(chunk); break; } } }, flush(controller) { flushBuffer(controller); if (fenceMode === "yaml-spec") { finaliseYamlSpec(controller); } else if (fenceMode === "yaml-edit") { finaliseYamlEdit(controller); } else if (fenceMode === "yaml-patch") { finaliseYamlPatch(controller); } else if (fenceMode === "diff") { finaliseDiff(controller); } closeTextBlock(controller); }, }); } /** * Convenience wrapper that pipes an AI SDK UI message stream through the * YAML transform, converting YAML spec/edit blocks into json-render patches. * * Drop-in replacement for `pipeJsonRender` from `@json-render/core`. * * @example * ```ts * import { pipeYamlRender } from "@json-render/yaml"; * * const stream = createUIMessageStream({ * execute: async ({ writer }) => { * writer.merge(pipeYamlRender(result.toUIMessageStream())); * }, * }); * return createUIMessageStreamResponse({ stream }); * ``` */ export function pipeYamlRender( stream: ReadableStream, options?: YamlTransformOptions, ): ReadableStream { return stream.pipeThrough( createYamlTransform(options) as unknown as TransformStream, ); } ================================================ FILE: packages/yaml/tsconfig.json ================================================ { "extends": "@internal/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/yaml/tsup.config.ts ================================================ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts"], format: ["cjs", "esm"], dts: true, sourcemap: true, clean: true, external: ["@json-render/core", "yaml", "diff"], }); ================================================ FILE: packages/yaml/vitest.config.ts ================================================ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["src/**/*.test.ts"], }, }); ================================================ FILE: packages/zustand/CHANGELOG.md ================================================ # @json-render/zustand ## 0.14.1 ### Patch Changes - Updated dependencies [43b7515] - @json-render/core@0.14.1 ## 0.14.0 ### Patch Changes - Updated dependencies [a8afd8b] - @json-render/core@0.14.0 ## 0.13.0 ### Patch Changes - Updated dependencies [5b32de8] - @json-render/core@0.13.0 ## 0.12.1 ### Patch Changes - Updated dependencies [54a1ecf] - @json-render/core@0.12.1 ## 0.12.0 ### Patch Changes - Updated dependencies [63c339b] - @json-render/core@0.12.0 ## 0.11.0 ### Patch Changes - Updated dependencies [3f1e71e] - @json-render/core@0.11.0 ## 0.10.0 ### Patch Changes - Updated dependencies [9cef4e9] - @json-render/core@0.10.0 ## 0.9.1 ### Patch Changes - @json-render/core@0.9.1 ## 0.9.0 ### Minor Changes - 1d755c1: External state store, store adapters, and bug fixes. ### New: External State Store The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode. - Added `StateStore` interface and `createStateStore()` factory to `@json-render/core` - `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop for controlled mode - When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored) - When `store` is omitted, everything works exactly as before (fully backward compatible) - Applied across all platform packages: react, react-native, react-pdf - Store utilities (`createStoreAdapter`, `immutableSetByPath`, `flattenToPointers`) available via `@json-render/core/store-utils` for building custom adapters ### New: Store Adapter Packages - `@json-render/zustand` — Zustand adapter for `StateStore` - `@json-render/redux` — Redux / Redux Toolkit adapter for `StateStore` - `@json-render/jotai` — Jotai adapter for `StateStore` ### Changed: `onStateChange` signature updated (breaking) The `onStateChange` callback now receives a single array of changed entries instead of being called once per path: ```ts // Before onStateChange?: (path: string, value: unknown) => void // After onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void ``` ### Fixed - Fix schema import to use server-safe `@json-render/react/schema` subpath, avoiding `createContext` crashes in Next.js App Router API routes - Fix chaining actions in `@json-render/react`, `@json-render/react-native`, and `@json-render/react-pdf` - Fix safely resolving inner type for Zod arrays in core schema ### Patch Changes - Updated dependencies [1d755c1] - @json-render/core@0.9.0 ================================================ FILE: packages/zustand/README.md ================================================ # @json-render/zustand Zustand adapter for json-render's `StateStore` interface. Wire a Zustand vanilla store as the state backend for json-render. ## Installation ```bash npm install @json-render/zustand @json-render/core @json-render/react zustand ``` > **Note:** This adapter requires Zustand v5+. Zustand v4 is not supported due to > breaking API changes in the vanilla store interface (`createStore`, `StoreApi`). ## Usage ```ts import { createStore } from "zustand/vanilla"; import { zustandStateStore } from "@json-render/zustand"; import { StateProvider } from "@json-render/react"; // 1. Create a Zustand vanilla store const bearStore = createStore(() => ({ count: 0, name: "Bear", })); // 2. Create the json-render StateStore adapter const store = zustandStateStore({ store: bearStore }); // 3. Use it {/* json-render reads/writes go through Zustand */} ``` ### With a nested slice ```ts const appStore = createStore(() => ({ ui: { count: 0 }, auth: { token: null }, })); const store = zustandStateStore({ store: appStore, selector: (s) => s.ui, updater: (next, s) => s.setState({ ui: next }), }); ``` ## API ### `zustandStateStore(options)` Creates a `StateStore` backed by a Zustand store. #### Options | Option | Type | Required | Description | |--------|------|----------|-------------| | `store` | `StoreApi` | Yes | A Zustand vanilla store (from `createStore` in `zustand/vanilla`) | | `selector` | `(state) => StateModel` | No | Select the json-render slice from the store state. Defaults to the entire state. | | `updater` | `(nextState, store) => void` | No | Apply the next state back to the Zustand store. Defaults to a shallow merge so that keys outside the json-render model are preserved. Override for nested slices, or pass `(next, s) => s.setState(next, true)` for full replacement. | ================================================ FILE: packages/zustand/package.json ================================================ { "name": "@json-render/zustand", "version": "0.14.1", "license": "Apache-2.0", "description": "Zustand adapter for json-render StateStore", "keywords": [ "json-render", "zustand", "state-management", "adapter" ], "repository": { "type": "git", "url": "git+https://github.com/vercel-labs/json-render.git", "directory": "packages/zustand" }, "homepage": "https://json-render.dev", "bugs": { "url": "https://github.com/vercel-labs/json-render/issues" }, "publishConfig": { "access": "public" }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } }, "files": [ "dist" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "check-types": "tsc --noEmit" }, "dependencies": { "@json-render/core": "workspace:*" }, "peerDependencies": { "zustand": ">=5.0.0" }, "devDependencies": { "@internal/typescript-config": "workspace:*", "tsup": "^8.0.2", "typescript": "^5.4.5", "zustand": "^5.0.11" } } ================================================ FILE: packages/zustand/src/index.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { createStore } from "zustand/vanilla"; import { zustandStateStore } from "./index"; describe("zustandStateStore", () => { it("get/set round-trip", () => { const zStore = createStore>()(() => ({ count: 0 })); const store = zustandStateStore({ store: zStore }); expect(store.get("/count")).toBe(0); store.set("/count", 42); expect(store.get("/count")).toBe(42); expect(store.getSnapshot().count).toBe(42); }); it("update round-trip with multiple values", () => { const zStore = createStore>()(() => ({})); const store = zustandStateStore({ store: zStore }); store.update({ "/a": 1, "/b": "hello" }); expect(store.get("/a")).toBe(1); expect(store.get("/b")).toBe("hello"); expect(store.getSnapshot()).toEqual({ a: 1, b: "hello" }); }); it("subscribe fires on set", () => { const zStore = createStore>()(() => ({})); const store = zustandStateStore({ store: zStore }); const listener = vi.fn(); store.subscribe(listener); store.set("/x", 1); expect(listener).toHaveBeenCalledTimes(1); }); it("subscribe fires on update", () => { const zStore = createStore>()(() => ({})); const store = zustandStateStore({ store: zStore }); const listener = vi.fn(); store.subscribe(listener); store.update({ "/a": 1, "/b": 2 }); expect(listener).toHaveBeenCalledTimes(1); }); it("unsubscribe stops notifications", () => { const zStore = createStore>()(() => ({})); const store = zustandStateStore({ store: zStore }); const listener = vi.fn(); const unsub = store.subscribe(listener); store.set("/x", 1); expect(listener).toHaveBeenCalledTimes(1); unsub(); store.set("/x", 2); expect(listener).toHaveBeenCalledTimes(1); }); it("getSnapshot immutability -- previous snapshot is not mutated", () => { const zStore = createStore>()(() => ({ user: { name: "Alice", age: 30 }, })); const store = zustandStateStore({ store: zStore }); const snap1 = store.getSnapshot(); store.set("/user/name", "Bob"); const snap2 = store.getSnapshot(); expect(snap1.user).toEqual({ name: "Alice", age: 30 }); expect((snap2.user as Record).name).toBe("Bob"); expect(snap1.user).not.toBe(snap2.user); }); it("structural sharing -- untouched branches keep references", () => { const zStore = createStore>()(() => ({ a: { x: 1 }, b: { y: 2 }, })); const store = zustandStateStore({ store: zStore }); const snap1 = store.getSnapshot(); store.set("/a/x", 99); const snap2 = store.getSnapshot(); expect(snap2.b).toBe(snap1.b); expect(snap2.a).not.toBe(snap1.a); }); it("getServerSnapshot returns same as getSnapshot", () => { const zStore = createStore>()(() => ({ x: 1 })); const store = zustandStateStore({ store: zStore }); expect(store.getServerSnapshot!()).toBe(store.getSnapshot()); store.set("/x", 2); expect(store.getServerSnapshot!()).toBe(store.getSnapshot()); }); it("set skips update when value is unchanged", () => { const zStore = createStore>()(() => ({ x: 1 })); const store = zustandStateStore({ store: zStore }); const snap1 = store.getSnapshot(); const listener = vi.fn(); store.subscribe(listener); store.set("/x", 1); expect(listener).not.toHaveBeenCalled(); expect(store.getSnapshot()).toBe(snap1); }); it("update skips update when no values changed", () => { const zStore = createStore>()(() => ({ a: 1, b: 2, })); const store = zustandStateStore({ store: zStore }); const snap1 = store.getSnapshot(); const listener = vi.fn(); store.subscribe(listener); store.update({ "/a": 1, "/b": 2 }); expect(listener).not.toHaveBeenCalled(); expect(store.getSnapshot()).toBe(snap1); }); it("subscribe does NOT fire when unrelated slice changes", () => { interface AppState extends Record { ui: Record; other: { value: string }; } const zStore = createStore()(() => ({ ui: { count: 0 }, other: { value: "a" }, })); const store = zustandStateStore({ store: zStore, selector: (s) => s.ui, updater: (next, s) => s.setState({ ui: next }), }); const listener = vi.fn(); store.subscribe(listener); zStore.setState({ other: { value: "b" } }); expect(listener).not.toHaveBeenCalled(); expect(store.get("/count")).toBe(0); }); it("default updater preserves keys outside the json-render model", () => { interface AppState extends Record { count: number; theme: string; } const zStore = createStore()(() => ({ count: 0, theme: "dark", })); const store = zustandStateStore({ store: zStore }); store.set("/count", 5); expect(store.get("/count")).toBe(5); expect(zStore.getState().theme).toBe("dark"); }); it("works with custom selector and updater", () => { interface AppState extends Record { ui: Record; } const zStore = createStore()(() => ({ ui: { count: 0 }, })); const store = zustandStateStore({ store: zStore, selector: (s) => s.ui, updater: (next, s) => s.setState({ ui: next }), }); store.set("/count", 5); expect(store.get("/count")).toBe(5); expect(zStore.getState().ui.count).toBe(5); }); }); ================================================ FILE: packages/zustand/src/index.ts ================================================ import type { StateModel, StateStore } from "@json-render/core"; import { createStoreAdapter } from "@json-render/core/store-utils"; import type { StoreApi } from "zustand"; export type { StateStore } from "@json-render/core"; /** * Options for {@link zustandStateStore}. */ export interface ZustandStateStoreOptions { /** A Zustand vanilla store (created with `createStore` from `zustand/vanilla`). */ store: StoreApi; /** * Select the json-render state slice from the Zustand store state. * Defaults to `(state) => state` (the entire store is the state model). */ selector?: (state: S) => StateModel; /** * Apply a state change back to the Zustand store. * Defaults to a shallow merge (`store.setState(next)`) so that keys * outside the json-render model are preserved. Override this if you use * a selector and only want to update a nested slice, or pass * `(next, s) => s.setState(next as S, true)` for full replacement. */ updater?: (nextState: StateModel, store: StoreApi) => void; } /** * Create a {@link StateStore} backed by a Zustand store. * * @example * ```ts * import { createStore } from "zustand/vanilla"; * import { zustandStateStore } from "@json-render/zustand"; * * const bearStore = createStore(() => ({ count: 0, name: "Bear" })); * * const store = zustandStateStore({ store: bearStore }); * * ... * ``` * * @example Using a selector for a nested slice: * ```ts * const appStore = createStore(() => ({ * ui: { count: 0 }, * auth: { token: null }, * })); * * const store = zustandStateStore({ * store: appStore, * selector: (s) => s.ui, * updater: (next, s) => s.setState({ ui: next }), * }); * ``` */ export function zustandStateStore( options: ZustandStateStoreOptions, ): StateStore { const { store, selector = (s: S) => s as StateModel, updater = (next, s) => s.setState(next as Partial), } = options; return createStoreAdapter({ getSnapshot: () => selector(store.getState()), setSnapshot: (next) => updater(next, store), subscribe(listener) { let prev = selector(store.getState()); return store.subscribe(() => { const current = selector(store.getState()); if (current !== prev) { prev = current; listener(); // Re-read after listener in case it triggered a synchronous dispatch; // absorb that change so it doesn't fire a duplicate notification. prev = selector(store.getState()); } }); }, }); } ================================================ FILE: packages/zustand/tsconfig.json ================================================ { "extends": "@internal/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/zustand/tsup.config.ts ================================================ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts"], format: ["cjs", "esm"], dts: true, sourcemap: true, clean: true, external: ["@json-render/core", "@json-render/core/store-utils", "zustand"], }); ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - "apps/*" - "examples/*" - "examples/stripe-app/*" - "packages/*" - "tests/*" ================================================ FILE: scripts/generate-og-images.mts ================================================ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import satori from "satori"; import { Resvg } from "@resvg/resvg-js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, ".."); const FONTS_DIR = join(ROOT, "apps/web/public"); const WIDTH = 1200; const HEIGHT = 630; type Framework = "nextjs" | "vite" | "sveltekit"; interface Example { dir: string; title: string; framework: Framework; } const EXAMPLES: Example[] = [ { dir: "chat", title: "Chat Example", framework: "nextjs" }, { dir: "dashboard", title: "Dashboard Example", framework: "nextjs" }, { dir: "image", title: "Image Example", framework: "nextjs" }, { dir: "no-ai", title: "No AI Example", framework: "nextjs" }, { dir: "react-email", title: "React Email Example", framework: "nextjs" }, { dir: "react-pdf", title: "React PDF Example", framework: "nextjs" }, { dir: "react-three-fiber", title: "React Three Fiber Example", framework: "nextjs" }, { dir: "remotion", title: "Remotion Example", framework: "nextjs" }, { dir: "solid", title: "Solid Example", framework: "vite" }, { dir: "svelte", title: "Svelte Example", framework: "vite" }, { dir: "svelte-chat", title: "Svelte Chat Example", framework: "sveltekit" }, { dir: "vue", title: "Vue Example", framework: "vite" }, { dir: "vite-renderers", title: "Multi-Renderer Example", framework: "vite" }, ]; function getOutputPath(example: Example): string { const base = join(ROOT, "examples", example.dir); switch (example.framework) { case "nextjs": return join(base, "app", "opengraph-image.png"); case "vite": return join(base, "public", "og-image.png"); case "sveltekit": return join(base, "static", "og-image.png"); } } function buildMarkup(title: string) { return { type: "div", props: { style: { width: "100%", height: "100%", display: "flex", flexDirection: "column", backgroundColor: "black", padding: "60px 80px", }, children: [ { type: "div", props: { style: { display: "flex", alignItems: "center", gap: "16px", }, children: [ { type: "svg", props: { width: 36, height: 36, viewBox: "0 0 16 16", fill: "white", children: { type: "path", props: { fillRule: "evenodd", clipRule: "evenodd", d: "M8 1L16 15H0L8 1Z", }, }, }, }, { type: "span", props: { style: { fontSize: 36, color: "#666", fontFamily: "Geist", fontWeight: 400, }, children: "/", }, }, { type: "span", props: { style: { fontSize: 36, fontFamily: "Geist Pixel Square", fontWeight: 500, color: "white", }, children: "json-render", }, }, ], }, }, { type: "div", props: { style: { display: "flex", flex: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", }, children: title.split("\n").map((line: string) => ({ type: "span", props: { style: { fontSize: 72, fontFamily: "Geist", fontWeight: 400, color: "white", letterSpacing: "-0.02em", textAlign: "center", lineHeight: 1.2, }, children: line, }, })), }, }, ], }, }; } async function main() { const [geistRegular, geistPixelSquare] = await Promise.all([ readFile(join(FONTS_DIR, "Geist-Regular.ttf")), readFile(join(FONTS_DIR, "GeistPixel-Square.ttf")), ]); const fonts = [ { name: "Geist", data: geistRegular, weight: 400 as const, style: "normal" as const }, { name: "Geist Pixel Square", data: geistPixelSquare, weight: 500 as const, style: "normal" as const }, ]; for (const example of EXAMPLES) { const svg = await satori(buildMarkup(example.title), { width: WIDTH, height: HEIGHT, fonts, }); const resvg = new Resvg(svg, { fitTo: { mode: "width", value: WIDTH }, }); const png = resvg.render().asPng(); const outPath = getOutputPath(example); await mkdir(dirname(outPath), { recursive: true }); await writeFile(outPath, png); console.log(` ${example.dir} -> ${outPath.replace(ROOT + "/", "")}`); } console.log(`\nGenerated ${EXAMPLES.length} OG images.`); } main().catch((err) => { console.error(err); process.exit(1); }); ================================================ FILE: skills/codegen/SKILL.md ================================================ --- name: codegen description: Code generation utilities for json-render. Use when generating code from UI specs, building custom code exporters, traversing specs, or serializing props for @json-render/codegen. --- # @json-render/codegen Framework-agnostic utilities for generating code from json-render UI trees. Use these to build custom code exporters for Next.js, Remix, or other frameworks. ## Installation ```bash npm install @json-render/codegen ``` ## Tree Traversal ```typescript import { traverseSpec, collectUsedComponents, collectStatePaths, collectActions, } from "@json-render/codegen"; // Walk the spec depth-first traverseSpec(spec, (element, key, depth, parent) => { console.log(`${" ".repeat(depth * 2)}${key}: ${element.type}`); }); // Get all component types used const components = collectUsedComponents(spec); // Set { "Card", "Metric", "Button" } // Get all state paths referenced const statePaths = collectStatePaths(spec); // Set { "analytics/revenue", "user/name" } // Get all action names const actions = collectActions(spec); // Set { "submit_form", "refresh_data" } ``` ## Serialization ```typescript import { serializePropValue, serializeProps, escapeString, type SerializeOptions, } from "@json-render/codegen"; // Serialize a single value serializePropValue("hello"); // { value: '"hello"', needsBraces: false } serializePropValue({ $state: "/user/name" }); // { value: '{ $state: "/user/name" }', needsBraces: true } // Serialize props for JSX serializeProps({ title: "Dashboard", columns: 3, disabled: true }); // 'title="Dashboard" columns={3} disabled' // Escape strings for code escapeString('hello "world"'); // 'hello \"world\"' ``` ### SerializeOptions ```typescript interface SerializeOptions { quotes?: "single" | "double"; indent?: number; } ``` ## Types ```typescript import type { GeneratedFile, CodeGenerator } from "@json-render/codegen"; const myGenerator: CodeGenerator = { generate(spec) { return [ { path: "package.json", content: "..." }, { path: "app/page.tsx", content: "..." }, ]; }, }; ``` ## Building a Custom Generator ```typescript import { collectUsedComponents, collectStatePaths, traverseSpec, serializeProps, type GeneratedFile, } from "@json-render/codegen"; import type { Spec } from "@json-render/core"; export function generateNextJSProject(spec: Spec): GeneratedFile[] { const files: GeneratedFile[] = []; const components = collectUsedComponents(spec); // Generate package.json, component files, main page... return files; } ``` ================================================ FILE: skills/core/SKILL.md ================================================ --- name: core description: Core package for defining schemas, catalogs, and AI prompt generation for json-render. Use when working with @json-render/core, defining schemas, creating catalogs, or building JSON specs for UI/video generation. --- # @json-render/core Core package for schema definition, catalog creation, and spec streaming. ## Key Concepts - **Schema**: Defines the structure of specs and catalogs (use `defineSchema`) - **Catalog**: Maps component/action names to their definitions (use `defineCatalog`) - **Spec**: JSON output from AI that conforms to the schema - **SpecStream**: JSONL streaming format for progressive spec building ## Defining a Schema ```typescript import { defineSchema } from "@json-render/core"; export const schema = defineSchema((s) => ({ spec: s.object({ // Define spec structure }), catalog: s.object({ components: s.map({ props: s.zod(), description: s.string(), }), }), }), { promptTemplate: myPromptTemplate, // Optional custom AI prompt }); ``` ## Creating a Catalog ```typescript import { defineCatalog } from "@json-render/core"; import { schema } from "./schema"; import { z } from "zod"; export const catalog = defineCatalog(schema, { components: { Button: { props: z.object({ label: z.string(), variant: z.enum(["primary", "secondary"]).nullable(), }), description: "Clickable button component", }, }, }); ``` ## Generating AI Prompts ```typescript const systemPrompt = catalog.prompt(); // Uses schema's promptTemplate const systemPrompt = catalog.prompt({ customRules: ["Rule 1", "Rule 2"] }); ``` ## SpecStream Utilities For streaming AI responses (JSONL patches): ```typescript import { createSpecStreamCompiler } from "@json-render/core"; const compiler = createSpecStreamCompiler(); // Process streaming chunks const { result, newPatches } = compiler.push(chunk); // Get final result const finalSpec = compiler.getResult(); ``` ## Dynamic Prop Expressions Any prop value can be a dynamic expression resolved at render time: - **`{ "$state": "/state/key" }`** - reads a value from the state model (one-way read) - **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components. - **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes. - **`{ "$cond": , "$then": , "$else": }`** - evaluates a visibility condition and picks a branch - **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates `${/path}` references with state values - **`{ "$computed": "fnName", "args": { "key": } }`** - calls a registered function with resolved args `$cond` uses the same syntax as visibility conditions (`$state`, `eq`, `neq`, `not`, arrays for AND). `$then` and `$else` can themselves be expressions (recursive). Components do not use a `statePath` prop for two-way binding. Instead, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). ```json { "color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }, "label": { "$template": "Welcome, ${/user/name}!" }, "fullName": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } } } ``` ```typescript import { resolvePropValue, resolveElementProps } from "@json-render/core"; const resolved = resolveElementProps(element.props, { stateModel: myState }); ``` ## State Watchers Elements can declare a `watch` field (top-level, sibling of type/props/children) to trigger actions when state values change: ```json { "type": "Select", "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] }, "watch": { "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } } }, "children": [] } ``` Watchers only fire on value changes, not on initial render. ## Validation Built-in validation functions: `required`, `email`, `url`, `numeric`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `matches`, `equalTo`, `lessThan`, `greaterThan`, `requiredIf`. Cross-field validation uses `$state` expressions in args: ```typescript import { check } from "@json-render/core"; check.required("Field is required"); check.matches("/form/password", "Passwords must match"); check.lessThan("/form/endDate", "Must be before end date"); check.greaterThan("/form/startDate", "Must be after start date"); check.requiredIf("/form/enableNotifications", "Required when enabled"); ``` ## User Prompt Builder Build structured user prompts with optional spec refinement and state context: ```typescript import { buildUserPrompt } from "@json-render/core"; // Fresh generation buildUserPrompt({ prompt: "create a todo app" }); // Refinement with edit modes (default: patch-only) buildUserPrompt({ prompt: "add a toggle", currentSpec: spec, editModes: ["patch", "merge"] }); // With runtime state buildUserPrompt({ prompt: "show data", state: { todos: [] } }); ``` Available edit modes: `"patch"` (RFC 6902 JSON Patch), `"merge"` (RFC 7396 Merge Patch), `"diff"` (unified diff). ## Spec Validation Validate spec structure and auto-fix common issues: ```typescript import { validateSpec, autoFixSpec } from "@json-render/core"; const { valid, issues } = validateSpec(spec); const fixed = autoFixSpec(spec); ``` ## Visibility Conditions Control element visibility with state-based conditions. `VisibilityContext` is `{ stateModel: StateModel }`. ```typescript import { visibility } from "@json-render/core"; // Syntax { "$state": "/path" } // truthiness { "$state": "/path", "not": true } // falsy { "$state": "/path", "eq": value } // equality [ cond1, cond2 ] // implicit AND // Helpers visibility.when("/path") // { $state: "/path" } visibility.unless("/path") // { $state: "/path", not: true } visibility.eq("/path", val) // { $state: "/path", eq: val } visibility.and(cond1, cond2) // { $and: [cond1, cond2] } visibility.or(cond1, cond2) // { $or: [cond1, cond2] } visibility.always // true visibility.never // false ``` ## Built-in Actions in Schema Schemas can declare `builtInActions` -- actions that are always available at runtime and auto-injected into prompts: ```typescript const schema = defineSchema(builder, { builtInActions: [ { name: "setState", description: "Update a value in the state model" }, ], }); ``` These appear in prompts as `[built-in]` and don't require handlers in `defineRegistry`. ## StateStore The `StateStore` interface allows external state management libraries (Redux, Zustand, XState, etc.) to be plugged into json-render renderers. The `createStateStore` factory creates a simple in-memory implementation: ```typescript import { createStateStore, type StateStore } from "@json-render/core"; const store = createStateStore({ count: 0 }); store.get("/count"); // 0 store.set("/count", 1); // updates and notifies subscribers store.update({ "/a": 1, "/b": 2 }); // batch update store.subscribe(() => { console.log(store.getSnapshot()); // { count: 1 } }); ``` The `StateStore` interface: `get(path)`, `set(path, value)`, `update(updates)`, `getSnapshot()`, `subscribe(listener)`. ## Key Exports | Export | Purpose | |--------|---------| | `defineSchema` | Create a new schema | | `defineCatalog` | Create a catalog from schema | | `createStateStore` | Create a framework-agnostic in-memory `StateStore` | | `resolvePropValue` | Resolve a single prop expression against data | | `resolveElementProps` | Resolve all prop expressions in an element | | `buildUserPrompt` | Build user prompts with refinement and state context | | `buildEditUserPrompt` | Build user prompt for editing existing specs | | `buildEditInstructions` | Generate prompt section for available edit modes | | `isNonEmptySpec` | Check if spec has root and at least one element | | `deepMergeSpec` | RFC 7396 deep merge (null deletes, arrays replace, objects recurse) | | `diffToPatches` | Generate RFC 6902 JSON Patch operations from object diff | | `EditMode` | Type: `"patch" \| "merge" \| "diff"` | | `validateSpec` | Validate spec structure | | `autoFixSpec` | Auto-fix common spec issues | | `createSpecStreamCompiler` | Stream JSONL patches into spec | | `createJsonRenderTransform` | TransformStream separating text from JSONL in mixed streams | | `parseSpecStreamLine` | Parse single JSONL line | | `applySpecStreamPatch` | Apply patch to object | | `StateStore` | Interface for plugging in external state management | | `ComputedFunction` | Function signature for `$computed` expressions | | `check` | TypeScript helpers for creating validation checks | | `BuiltInAction` | Type for built-in action definitions (`name` + `description`) | | `ActionBinding` | Action binding type (includes `preventDefault` field) | ================================================ FILE: skills/image/SKILL.md ================================================ --- name: image description: Image renderer for json-render that turns JSON specs into SVG and PNG images via Satori. Use when working with @json-render/image, generating OG images from JSON, creating social cards, or rendering AI-generated image specs. --- # @json-render/image Image renderer that converts JSON specs into SVG and PNG images using Satori. ## Quick Start ```typescript import { renderToPng } from "@json-render/image/render"; import type { Spec } from "@json-render/core"; const spec: Spec = { root: "frame", elements: { frame: { type: "Frame", props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" }, children: ["heading"], }, heading: { type: "Heading", props: { text: "Hello World", level: "h1", color: "#ffffff" }, children: [], }, }, }; const png = await renderToPng(spec, { fonts: [{ name: "Inter", data: fontData, weight: 400, style: "normal" }], }); ``` ## Using Standard Components ```typescript import { defineCatalog } from "@json-render/core"; import { schema, standardComponentDefinitions } from "@json-render/image"; export const imageCatalog = defineCatalog(schema, { components: standardComponentDefinitions, }); ``` ## Adding Custom Components ```typescript import { z } from "zod"; const catalog = defineCatalog(schema, { components: { ...standardComponentDefinitions, Badge: { props: z.object({ label: z.string(), color: z.string().nullable() }), slots: [], description: "A colored badge label", }, }, }); ``` ## Standard Components | Component | Category | Description | |-----------|----------|-------------| | `Frame` | Root | Root container. Defines width, height, background. Must be root. | | `Box` | Layout | Container with padding, margin, border, absolute positioning | | `Row` | Layout | Horizontal flex layout | | `Column` | Layout | Vertical flex layout | | `Heading` | Content | h1-h4 heading text | | `Text` | Content | Body text with full styling | | `Image` | Content | Image from URL | | `Divider` | Decorative | Horizontal line separator | | `Spacer` | Decorative | Empty vertical space | ## Key Exports | Export | Purpose | |--------|---------| | `renderToSvg` | Render spec to SVG string | | `renderToPng` | Render spec to PNG buffer (requires `@resvg/resvg-js`) | | `schema` | Image element schema | | `standardComponents` | Pre-built component registry | | `standardComponentDefinitions` | Catalog definitions for AI prompts | ## Sub-path Exports | Export | Description | |--------|-------------| | `@json-render/image` | Full package: schema, components, render functions | | `@json-render/image/server` | Schema and catalog definitions only (no React/Satori) | | `@json-render/image/catalog` | Standard component definitions and types | | `@json-render/image/render` | Render functions only | ================================================ FILE: skills/jotai/SKILL.md ================================================ --- name: jotai description: Jotai adapter for json-render's StateStore interface. Use when integrating json-render with Jotai for state management via @json-render/jotai. --- # @json-render/jotai Jotai adapter for json-render's `StateStore` interface. Wire a Jotai atom as the state backend for json-render. ## Installation ```bash npm install @json-render/jotai @json-render/core @json-render/react jotai ``` ## Usage ```tsx import { atom } from "jotai"; import { jotaiStateStore } from "@json-render/jotai"; import { StateProvider } from "@json-render/react"; // 1. Create an atom that holds the json-render state const uiAtom = atom>({ count: 0 }); // 2. Create the json-render StateStore adapter const store = jotaiStateStore({ atom: uiAtom }); // 3. Use it {/* json-render reads/writes go through Jotai */} ``` ### With a Shared Jotai Store When your app already uses a Jotai `` with a custom store, pass it so both json-render and your components share the same state: ```tsx import { atom, createStore } from "jotai"; import { Provider as JotaiProvider } from "jotai/react"; import { jotaiStateStore } from "@json-render/jotai"; import { StateProvider } from "@json-render/react"; const jStore = createStore(); const uiAtom = atom>({ count: 0 }); const store = jotaiStateStore({ atom: uiAtom, store: jStore }); {/* Both json-render and useAtom() see the same state */} ``` ## API ### `jotaiStateStore(options)` Creates a `StateStore` backed by a Jotai atom. | Option | Type | Required | Description | |--------|------|----------|-------------| | `atom` | `WritableAtom` | Yes | A writable atom holding the state model | | `store` | Jotai `Store` | No | The Jotai store instance. Defaults to a new store. Pass your own to share state with ``. | ================================================ FILE: skills/mcp/SKILL.md ================================================ --- name: mcp description: MCP Apps integration for json-render. Use when building MCP servers that render interactive UIs in Claude, ChatGPT, Cursor, or VS Code, or when integrating json-render with the Model Context Protocol. --- # @json-render/mcp MCP Apps integration that serves json-render UIs as interactive MCP Apps inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients. ## Quick Start ### Server (Node.js) ```typescript import { createMcpApp } from "@json-render/mcp"; import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import fs from "node:fs"; const catalog = defineCatalog(schema, { components: { ...shadcnComponentDefinitions }, actions: {}, }); const server = createMcpApp({ name: "My App", version: "1.0.0", catalog, html: fs.readFileSync("dist/index.html", "utf-8"), }); await server.connect(new StdioServerTransport()); ``` ### Client (React, inside iframe) ```tsx import { useJsonRenderApp } from "@json-render/mcp/app"; import { JSONUIProvider, Renderer } from "@json-render/react"; function McpAppView({ registry }) { const { spec, loading, error } = useJsonRenderApp(); if (error) return

Error: {error.message}
; if (!spec) return
Waiting...
; return ( ); } ``` ## Architecture 1. `createMcpApp()` creates an `McpServer` that registers a `render-ui` tool and a `ui://` HTML resource 2. The tool description includes the catalog prompt so the LLM knows how to generate valid specs 3. The HTML resource is a Vite-bundled single-file React app with json-render renderers 4. Inside the iframe, `useJsonRenderApp()` connects to the host via `postMessage` and renders specs ## Server API - `createMcpApp(options)` - main entry, creates a full MCP server - `registerJsonRenderTool(server, options)` - register a json-render tool on an existing server - `registerJsonRenderResource(server, options)` - register the UI resource ## Client API (`@json-render/mcp/app`) - `useJsonRenderApp(options?)` - React hook, returns `{ spec, loading, connected, error, callServerTool }` - `buildAppHtml(options)` - generate HTML from bundled JS/CSS ## Building the iframe HTML Bundle the React app into a single self-contained HTML file using Vite + `vite-plugin-singlefile`: ```typescript // vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { viteSingleFile } from "vite-plugin-singlefile"; export default defineConfig({ plugins: [react(), viteSingleFile()], build: { outDir: "dist" }, }); ``` ## Client Configuration ### Cursor (`.cursor/mcp.json`) ```json { "mcpServers": { "my-app": { "command": "npx", "args": ["tsx", "server.ts", "--stdio"] } } } ``` ### Claude Desktop ```json { "mcpServers": { "my-app": { "command": "npx", "args": ["tsx", "/path/to/server.ts", "--stdio"] } } } ``` ## Dependencies ```bash # Server npm install @json-render/mcp @json-render/core @modelcontextprotocol/sdk # Client (iframe) npm install @json-render/react @json-render/shadcn react react-dom # Build tools npm install -D vite @vitejs/plugin-react vite-plugin-singlefile ``` ================================================ FILE: skills/react/SKILL.md ================================================ --- name: react description: React renderer for json-render that turns JSON specs into React components. Use when working with @json-render/react, building React UIs from JSON, creating component catalogs, or rendering AI-generated specs. --- # @json-render/react React renderer that converts JSON specs into React component trees. ## Quick Start ```typescript import { defineRegistry, Renderer } from "@json-render/react"; import { catalog } from "./catalog"; const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) =>
{props.title}{children}
, }, }); function App({ spec }) { return ; } ``` ## Creating a Catalog ```typescript import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { defineRegistry } from "@json-render/react"; import { z } from "zod"; // Create catalog with props schemas export const catalog = defineCatalog(schema, { components: { Button: { props: z.object({ label: z.string(), variant: z.enum(["primary", "secondary"]).nullable(), }), description: "Clickable button", }, Card: { props: z.object({ title: z.string() }), description: "Card container with title", }, }, }); // Define component implementations with type-safe props const { registry } = defineRegistry(catalog, { components: { Button: ({ props }) => ( ), Card: ({ props, children }) => (

{props.title}

{children}
), }, }); ``` ## Spec Structure (Element Tree) The React schema uses an element tree format: ```json { "root": { "type": "Card", "props": { "title": "Hello" }, "children": [ { "type": "Button", "props": { "label": "Click me" } } ] } } ``` ## Visibility Conditions Use `visible` on elements to show/hide based on state. New syntax: `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`, `{ "$state": "/path", "not": true }`, `{ "$and": [cond1, cond2] }` for AND, `{ "$or": [cond1, cond2] }` for OR. Helpers: `visibility.when("/path")`, `visibility.unless("/path")`, `visibility.eq("/path", val)`, `visibility.and(cond1, cond2)`, `visibility.or(cond1, cond2)`. ## Providers | Provider | Purpose | |----------|---------| | `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. | | `ActionProvider` | Handle actions dispatched via the event system | | `VisibilityProvider` | Enable conditional rendering based on state | | `ValidationProvider` | Form field validation | ### External Store (Controlled Mode) Pass a `StateStore` to `StateProvider` (or `JSONUIProvider` / `createRenderer`) to use external state management (Redux, Zustand, XState, etc.): ```tsx import { createStateStore, type StateStore } from "@json-render/react"; const store = createStateStore({ count: 0 }); {children} // Mutate from anywhere — React re-renders automatically: store.set("/count", 1); ``` When `store` is provided, `initialState` and `onStateChange` are ignored. ## Dynamic Prop Expressions Any prop value can be a data-driven expression resolved by the renderer before components receive props: - **`{ "$state": "/state/key" }`** - reads from state model (one-way read) - **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components. - **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes. - **`{ "$cond": , "$then": , "$else": }`** - conditional value - **`{ "$template": "Hello, ${/name}!" }`** - interpolates state values into strings - **`{ "$computed": "fn", "args": { ... } }`** - calls registered functions with resolved args ```json { "type": "Input", "props": { "value": { "$bindState": "/form/email" }, "placeholder": "Email" } } ``` Components do not use a `statePath` prop for two-way binding. Use `{ "$bindState": "/path" }` on the natural value prop instead. Components receive already-resolved props. For two-way bound props, use the `useBoundProp` hook with the `bindings` map the renderer provides. Register `$computed` functions via the `functions` prop on `JSONUIProvider` or `createRenderer`: ```tsx `${args.first} ${args.last}` }} > ``` ## Event System Components use `emit` to fire named events, or `on()` to get an event handle with metadata. The element's `on` field maps events to action bindings: ```tsx // Simple event firing Button: ({ props, emit }) => ( ), // Event handle with metadata (e.g. preventDefault) Link: ({ props, on }) => { const click = on("click"); return ( { if (click.shouldPreventDefault) e.preventDefault(); click.emit(); }}>{props.label} ); }, ``` ```json { "type": "Button", "props": { "label": "Submit" }, "on": { "press": { "action": "submit" } } } ``` The `EventHandle` returned by `on()` has: `emit()`, `shouldPreventDefault` (boolean), and `bound` (boolean). ## State Watchers Elements can declare a `watch` field (top-level, sibling of type/props/children) to trigger actions when state values change: ```json { "type": "Select", "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] }, "watch": { "/form/country": { "action": "loadCities" } }, "children": [] } ``` ## Built-in Actions The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in catalog `actions`: ```json { "action": "setState", "params": { "statePath": "/activeTab", "value": "home" } } { "action": "pushState", "params": { "statePath": "/items", "value": { "text": "New" } } } { "action": "removeState", "params": { "statePath": "/items", "index": 0 } } { "action": "validateForm", "params": { "statePath": "/formResult" } } ``` `validateForm` validates all registered fields and writes `{ valid, errors }` to state. Note: `statePath` in action params (e.g. `setState.statePath`) targets the mutation path. Two-way binding in component props uses `{ "$bindState": "/path" }` on the value prop, not `statePath`. ## useBoundProp For form components that need two-way binding, use `useBoundProp` with the `bindings` map the renderer provides when a prop uses `{ "$bindState": "/path" }` or `{ "$bindItem": "field" }`: ```tsx import { useBoundProp } from "@json-render/react"; Input: ({ element, bindings }) => { const [value, setValue] = useBoundProp( element.props.value, bindings?.value ); return ( setValue(e.target.value)} /> ); }, ``` `useBoundProp(propValue, bindingPath)` returns `[value, setValue]`. The `value` is the resolved prop; `setValue` writes back to the bound state path (no-op if not bound). ## BaseComponentProps For building reusable component libraries not tied to a specific catalog (e.g. `@json-render/shadcn`): ```typescript import type { BaseComponentProps } from "@json-render/react"; const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
{props.title}{children}
); ``` ## defineRegistry `defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it. ## Key Exports | Export | Purpose | |--------|---------| | `defineRegistry` | Create a type-safe component registry from a catalog | | `Renderer` | Render a spec using a registry | | `schema` | Element tree schema (includes built-in state actions: setState, pushState, removeState, validateForm) | | `useStateStore` | Access state context | | `useStateValue` | Get single value from state | | `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | | `useActions` | Access actions context | | `useAction` | Get a single action dispatch function | | `useOptionalValidation` | Non-throwing variant of useValidation (returns null if no provider) | | `useUIStream` | Stream specs from an API endpoint | | `createStateStore` | Create a framework-agnostic in-memory `StateStore` | | `StateStore` | Interface for plugging in external state management | | `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries | | `EventHandle` | Event handle type (`emit`, `shouldPreventDefault`, `bound`) | | `ComponentContext` | Typed component context (catalog-aware) | ================================================ FILE: skills/react-email/SKILL.md ================================================ --- name: react-email description: React Email renderer for json-render that turns JSON specs into HTML or plain-text emails using @react-email/components and @react-email/render. Use when working with @json-render/react-email, building transactional or marketing emails from JSON, creating email catalogs, rendering AI-generated email specs, or when the user mentions react-email, HTML email, or transactional email. metadata: tags: react-email, email, json-render, html email, transactional email --- # @json-render/react-email React Email renderer that converts JSON specs into HTML or plain-text email output. ## Quick Start ```typescript import { renderToHtml } from "@json-render/react-email"; import { schema, standardComponentDefinitions } from "@json-render/react-email"; import { defineCatalog } from "@json-render/core"; const catalog = defineCatalog(schema, { components: standardComponentDefinitions, }); const spec = { root: "html-1", elements: { "html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] }, "head-1": { type: "Head", props: {}, children: [] }, "body-1": { type: "Body", props: { style: { backgroundColor: "#f6f9fc" } }, children: ["container-1"], }, "container-1": { type: "Container", props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } }, children: ["heading-1", "text-1"], }, "heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] }, "text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] }, }, }; const html = await renderToHtml(spec); ``` ## Spec Structure (Element Tree) Same flat element tree as `@json-render/react`: `root` key plus `elements` map. Root must be `Html`; children of `Html` should be `Head` and `Body`. Use `Container` (e.g. max-width 600px) inside `Body` for client-safe layout. ## Creating a Catalog and Registry ```typescript import { defineCatalog } from "@json-render/core"; import { schema, defineRegistry, renderToHtml } from "@json-render/react-email"; import { standardComponentDefinitions } from "@json-render/react-email/catalog"; import { Container, Heading, Text } from "@react-email/components"; import { z } from "zod"; const catalog = defineCatalog(schema, { components: { ...standardComponentDefinitions, Alert: { props: z.object({ message: z.string(), variant: z.enum(["info", "success", "warning"]).nullable(), }), slots: [], description: "A highlighted message block", }, }, actions: {}, }); const { registry } = defineRegistry(catalog, { components: { Alert: ({ props }) => ( {props.message} ), }, }); const html = await renderToHtml(spec, { registry }); ``` ## Server-Side Render APIs | Function | Purpose | |----------|---------| | `renderToHtml(spec, options?)` | Render spec to HTML email string | | `renderToPlainText(spec, options?)` | Render spec to plain-text email string | `RenderOptions`: `registry`, `includeStandard` (default true), `state` (for `$state` / `$cond`). ## Visibility and State Supports `visible` conditions, `$state`, `$cond`, repeat (`repeat.statePath`), and the same expression syntax as `@json-render/react`. Use `state` in `RenderOptions` when rendering server-side so expressions resolve. ## Server-Safe Import Import schema and catalog without React or `@react-email/components`: ```typescript import { schema, standardComponentDefinitions } from "@json-render/react-email/server"; ``` ## Key Exports | Export | Purpose | |--------|---------| | `defineRegistry` | Create type-safe component registry from catalog | | `Renderer` | Render spec in browser (e.g. preview); use with `JSONUIProvider` for state/actions | | `createRenderer` | Standalone renderer component with state/actions/validation | | `renderToHtml` | Server: spec to HTML string | | `renderToPlainText` | Server: spec to plain-text string | | `schema` | Email element schema | | `standardComponents` | Pre-built component implementations | | `standardComponentDefinitions` | Catalog definitions (Zod props) | ## Sub-path Exports | Path | Purpose | |------|---------| | `@json-render/react-email` | Full package | | `@json-render/react-email/server` | Schema and catalog only (no React) | | `@json-render/react-email/catalog` | Standard component definitions and types | | `@json-render/react-email/render` | Render functions only | ## Standard Components All components accept a `style` prop (object) for inline styles. Use inline styles for email client compatibility; avoid external CSS. ### Document structure | Component | Description | |-----------|-------------| | `Html` | Root wrapper (lang, dir). Children: Head, Body. | | `Head` | Email head section. | | `Body` | Body wrapper; use `style` for background. | ### Layout | Component | Description | |-----------|-------------| | `Container` | Constrain width (e.g. max-width 600px). | | `Section` | Group content; table-based for compatibility. | | `Row` | Horizontal row. | | `Column` | Column in a Row; set width via style. | ### Content | Component | Description | |-----------|-------------| | `Heading` | Heading text (as: h1–h6). | | `Text` | Body text. | | `Link` | Hyperlink (text, href). | | `Button` | CTA link styled as button (text, href). | | `Image` | Image from URL (src, alt, width, height). | | `Hr` | Horizontal rule. | ### Utility | Component | Description | |-----------|-------------| | `Preview` | Inbox preview text (inside Html). | | `Markdown` | Markdown content as email-safe HTML. | ## Email Best Practices - Keep width constrained (e.g. Container max-width 600px). - Use inline styles or React Email's style props; many clients strip `