diff --git a/.gitignore b/.gitignore index 628e6e7b1..f1718dadf 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ node_modules # Build output dist .next +.svelte-kit *.tsbuildinfo typings diff --git a/examples/svelte-chat/.env.example b/examples/svelte-chat/.env.example new file mode 100644 index 000000000..e9839107a --- /dev/null +++ b/examples/svelte-chat/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY=your-openai-api-key-here diff --git a/examples/svelte-chat/.gitignore b/examples/svelte-chat/.gitignore new file mode 100644 index 000000000..ca1328b5d --- /dev/null +++ b/examples/svelte-chat/.gitignore @@ -0,0 +1,6 @@ +node_modules +.svelte-kit +build +.env +.env.* +!.env.example diff --git a/examples/svelte-chat/README.md b/examples/svelte-chat/README.md new file mode 100644 index 000000000..8bfdd2751 --- /dev/null +++ b/examples/svelte-chat/README.md @@ -0,0 +1,75 @@ +# OpenUI Svelte Chat + +A chat application built with [SvelteKit](https://svelte.dev/docs/kit), [Vercel AI SDK](https://ai-sdk.dev), and [`@openuidev/svelte-lang`](../../packages/svelte-lang/) — demonstrating how to render structured LLM output as live Svelte components. + +## How it works + +1. **User sends a message** via the chat input +2. **Server streams a response** using the Vercel AI SDK with OpenAI, guided by a system prompt written in openui-lang syntax +3. **`@openuidev/svelte-lang` Renderer** parses the streaming openui-lang text and renders it as Svelte components in real time +4. **Tool calls** (weather, stocks, math, web search) are displayed inline with status indicators + +## Setup + +### Prerequisites + +- Node.js 18+ +- [pnpm](https://pnpm.io/) +- An OpenAI API key + +### Install dependencies + +From the monorepo root: + +```bash +pnpm install +``` + +### Configure environment + +```bash +cp .env.example .env +``` + +Edit `.env` and add your OpenAI API key: + +``` +OPENAI_API_KEY=sk-... +``` + +### Run + +```bash +pnpm --filter svelte-chat dev +``` + +Open [http://localhost:5173](http://localhost:5173). + +## Project structure + +``` +src/ +├── routes/ +│ ├── +page.svelte # Chat UI with AI SDK Chat class + OpenUI Renderer +│ ├── +layout.svelte # Root layout (imports Tailwind) +│ ├── +layout.ts # Disables SSR (client-side rendering) +│ └── api/chat/+server.ts # AI SDK streaming endpoint +├── lib/ +│ ├── library.ts # OpenUI component definitions (Stack, Card, TextContent, Button) +│ ├── tools.ts # AI tool definitions (weather, stocks, math, search) +│ └── components/ # Svelte component renderers +│ ├── Stack.svelte +│ ├── Card.svelte +│ ├── TextContent.svelte +│ └── Button.svelte +└── generated/ + └── system-prompt.txt # LLM system prompt describing the openui-lang syntax +``` + +## Adding components + +1. Create a Svelte component in `src/lib/components/` +2. Define it with `defineComponent()` in `src/lib/library.ts` +3. Add its signature to `src/generated/system-prompt.txt` + +See the [`@openuidev/svelte-lang` README](../../packages/svelte-lang/README.md) for the full API. diff --git a/examples/svelte-chat/package.json b/examples/svelte-chat/package.json new file mode 100644 index 000000000..36266ef87 --- /dev/null +++ b/examples/svelte-chat/package.json @@ -0,0 +1,28 @@ +{ + "name": "svelte-chat", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@ai-sdk/openai": "^3.0.41", + "@ai-sdk/svelte": "^3.0.0", + "@openuidev/svelte-lang": "workspace:*", + "ai": "^6.0.116", + "chart.js": "^4.5.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^4.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4", + "svelte": "^5.0.0", + "tailwindcss": "^4", + "typescript": "^5", + "vite": "^6.0.0" + } +} diff --git a/examples/svelte-chat/src/app.css b/examples/svelte-chat/src/app.css new file mode 100644 index 000000000..f1d8c73cd --- /dev/null +++ b/examples/svelte-chat/src/app.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/examples/svelte-chat/src/app.d.ts b/examples/svelte-chat/src/app.d.ts new file mode 100644 index 000000000..f52e41baa --- /dev/null +++ b/examples/svelte-chat/src/app.d.ts @@ -0,0 +1,7 @@ +/// + +declare global { + namespace App {} +} + +export {}; diff --git a/examples/svelte-chat/src/app.html b/examples/svelte-chat/src/app.html new file mode 100644 index 000000000..3fff5cf8a --- /dev/null +++ b/examples/svelte-chat/src/app.html @@ -0,0 +1,12 @@ + + + + + + OpenUI Svelte Chat + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/examples/svelte-chat/src/lib/components/Button.svelte b/examples/svelte-chat/src/lib/components/Button.svelte new file mode 100644 index 000000000..4610c74a2 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/Button.svelte @@ -0,0 +1,21 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/Card.svelte b/examples/svelte-chat/src/lib/components/Card.svelte new file mode 100644 index 000000000..6d52a10e7 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/Card.svelte @@ -0,0 +1,19 @@ + + +
+ {#if props.title} +

{props.title}

+ {/if} + {#if props.children} +
+ {@render renderNode(props.children)} +
+ {/if} +
diff --git a/examples/svelte-chat/src/lib/components/Chart.svelte b/examples/svelte-chat/src/lib/components/Chart.svelte new file mode 100644 index 000000000..302b27dc5 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/Chart.svelte @@ -0,0 +1,128 @@ + + +
+ {#if props.title} +

{props.title}

+ {/if} +
+ +
+
diff --git a/examples/svelte-chat/src/lib/components/Stack.svelte b/examples/svelte-chat/src/lib/components/Stack.svelte new file mode 100644 index 000000000..da7273c36 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/Stack.svelte @@ -0,0 +1,14 @@ + + +
+ {#if props.children} + {@render renderNode(props.children)} + {/if} +
diff --git a/examples/svelte-chat/src/lib/components/TextContent.svelte b/examples/svelte-chat/src/lib/components/TextContent.svelte new file mode 100644 index 000000000..ba49ae912 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/TextContent.svelte @@ -0,0 +1,7 @@ + + +

{props.text ?? ""}

diff --git a/examples/svelte-chat/src/lib/library.ts b/examples/svelte-chat/src/lib/library.ts new file mode 100644 index 000000000..8732f1010 --- /dev/null +++ b/examples/svelte-chat/src/lib/library.ts @@ -0,0 +1,104 @@ +import { createLibrary, defineComponent, type PromptOptions } from "@openuidev/svelte-lang"; +import { z } from "zod"; +import Button from "./components/Button.svelte"; +import Card from "./components/Card.svelte"; +import Chart from "./components/Chart.svelte"; +import Stack from "./components/Stack.svelte"; +import TextContent from "./components/TextContent.svelte"; + +const TextContentDef = defineComponent({ + name: "TextContent", + props: z.object({ text: z.string() }), + description: "Displays a block of text. Supports markdown formatting within the string.", + component: TextContent, +}); + +const ButtonDef = defineComponent({ + name: "Button", + props: z.object({ + label: z.string(), + action: z.string().optional(), + }), + description: + "A clickable button. The label is shown to the user and used as the follow-up message.", + component: Button, +}); + +const ChartDef = defineComponent({ + name: "Chart", + props: z.object({ + title: z.string(), + type: z.enum(["bar", "line", "pie", "doughnut"]), + labels: z.array(z.string()), + values: z.array(z.number()), + datasetLabel: z.string().optional(), + }), + description: + "Renders a chart. Use bar for comparisons, line for trends, pie/doughnut for proportions.", + component: Chart, +}); + +const CardDef = defineComponent({ + name: "Card", + props: z.object({ + title: z.string(), + children: z.array(z.union([TextContentDef.ref, ButtonDef.ref, ChartDef.ref])), + }), + description: "A card container with a title and child components", + component: Card, +}); + +const StackDef = defineComponent({ + name: "Stack", + props: z.object({ + children: z.array(z.union([CardDef.ref, TextContentDef.ref, ButtonDef.ref, ChartDef.ref])), + }), + description: "Vertical layout container. Use as the root.", + component: Stack, +}); + +export const library = createLibrary({ + components: [TextContentDef, ButtonDef, ChartDef, CardDef, StackDef], + root: "Stack", +}); + +export const promptOptions: PromptOptions = { + additionalRules: [ + "Always use Stack as the root component.", + "Group related content in Card components with descriptive titles.", + "Use TextContent for all text output. You can use markdown within the text string.", + "Use Button for suggested follow-up actions the user might want to take.", + "For multi-section responses, use multiple Card components inside the root Stack.", + "Prefer using references for readability and better streaming performance.", + "Keep TextContent strings focused — use multiple TextContent components for different paragraphs or points.", + "Never nest Stack inside Stack directly.", + "Use Chart for data visualization. Choose bar for comparisons, line for trends, pie/doughnut for proportions.", + "Chart labels and values arrays must have the same length.", + ], + examples: [ + `User: What is Svelte? + +t1 = TextContent("Svelte is a modern JavaScript framework that shifts work from the browser to a compile step. Unlike React or Vue, Svelte compiles your components into efficient imperative code that directly manipulates the DOM.") +t2 = TextContent("**No virtual DOM** — Svelte updates the DOM surgically when state changes, resulting in excellent runtime performance.") +t3 = TextContent("**Less boilerplate** — Svelte's syntax is concise and intuitive, letting you write less code to achieve the same results.") +t4 = TextContent("**Built-in reactivity** — Simple variable assignments trigger UI updates. No hooks or special APIs needed.") +intro = Card("What is Svelte?", [t1]) +features = Card("Key Features", [t2, t3, t4]) +cta = Button("Tell me about Svelte 5") +root = Stack([intro, features, cta])`, + `User: What's the weather like? + +t1 = TextContent("I can look up the current weather for any city. Just tell me which location you're interested in!") +card = Card("Weather Lookup", [t1]) +b1 = Button("Weather in New York") +b2 = Button("Weather in Tokyo") +root = Stack([card, b1, b2])`, + `User: Show me the top 5 programming languages by popularity + +root = Stack([card, cta]) +chart = Chart("Programming Language Popularity", "bar", ["Python", "JavaScript", "Java", "C++", "TypeScript"], [30, 25, 18, 12, 10], "% Market Share") +t1 = TextContent("Python leads with 30% market share, driven by AI/ML adoption. JavaScript remains dominant for web development at 25%.") +card = Card("Language Trends", [chart, t1]) +cta = Button("Compare Python vs JavaScript")`, + ], +}; diff --git a/examples/svelte-chat/src/lib/tools.ts b/examples/svelte-chat/src/lib/tools.ts new file mode 100644 index 000000000..bf7b106d6 --- /dev/null +++ b/examples/svelte-chat/src/lib/tools.ts @@ -0,0 +1,117 @@ +import { tool } from "ai"; +import { z } from "zod"; + +export const tools = { + get_weather: tool({ + description: "Get current weather for a location.", + inputSchema: z.object({ + location: z.string().describe("City name"), + }), + execute: async ({ location }) => { + await new Promise((r) => setTimeout(r, 800)); + const knownTemps: Record = { + tokyo: 22, + "san francisco": 18, + london: 14, + "new york": 25, + paris: 19, + sydney: 27, + mumbai: 33, + berlin: 16, + }; + const conditions = ["Sunny", "Partly Cloudy", "Cloudy", "Light Rain", "Clear Skies"]; + const temp = knownTemps[location.toLowerCase()] ?? Math.floor(Math.random() * 30 + 5); + const condition = conditions[Math.floor(Math.random() * conditions.length)]; + return { + location, + temperature_celsius: temp, + temperature_fahrenheit: Math.round(temp * 1.8 + 32), + condition, + humidity_percent: Math.floor(Math.random() * 40 + 40), + wind_speed_kmh: Math.floor(Math.random() * 25 + 5), + forecast: [ + { day: "Tomorrow", high: temp + 2, low: temp - 4, condition: "Partly Cloudy" }, + { day: "Day After", high: temp + 1, low: temp - 3, condition: "Sunny" }, + ], + }; + }, + }), + + get_stock_price: tool({ + description: "Get stock price for a ticker symbol.", + inputSchema: z.object({ + symbol: z.string().describe("Ticker symbol, e.g. AAPL"), + }), + execute: async ({ symbol }) => { + await new Promise((r) => setTimeout(r, 600)); + const s = symbol.toUpperCase(); + const knownPrices: Record = { + AAPL: 189.84, + GOOGL: 141.8, + TSLA: 248.42, + MSFT: 378.91, + AMZN: 178.25, + NVDA: 875.28, + META: 485.58, + }; + const price = knownPrices[s] ?? Math.floor(Math.random() * 500 + 20); + const change = parseFloat((Math.random() * 8 - 4).toFixed(2)); + return { + symbol: s, + price: parseFloat((price + change).toFixed(2)), + change, + change_percent: parseFloat(((change / price) * 100).toFixed(2)), + volume: `${(Math.random() * 50 + 10).toFixed(1)}M`, + day_high: parseFloat((price + Math.abs(change) + 1.5).toFixed(2)), + day_low: parseFloat((price - Math.abs(change) - 1.2).toFixed(2)), + }; + }, + }), + + calculate: tool({ + description: "Evaluate a math expression.", + inputSchema: z.object({ + expression: z.string().describe("Math expression to evaluate"), + }), + execute: async ({ expression }) => { + await new Promise((r) => setTimeout(r, 300)); + try { + const sanitized = expression.replace( + /[^0-9+\-*/().%\s,Math.sqrtpowabsceilfloorround]/g, + "", + ); + const result = new Function(`return (${sanitized})`)(); + return { expression, result: Number(result) }; + } catch { + return { expression, error: "Invalid expression" }; + } + }, + }), + + search_web: tool({ + description: "Search the web for information.", + inputSchema: z.object({ + query: z.string().describe("Search query"), + }), + execute: async ({ query }) => { + await new Promise((r) => setTimeout(r, 1000)); + return { + query, + results: [ + { + title: `Top result for "${query}"`, + snippet: `Comprehensive overview of ${query} with the latest information.`, + }, + { + title: `${query} - Latest News`, + snippet: `Recent developments and updates related to ${query}.`, + }, + { + title: `Understanding ${query}`, + snippet: `An in-depth guide explaining everything about ${query}.`, + }, + ], + }; + }, + }), +}; diff --git a/examples/svelte-chat/src/routes/+layout.svelte b/examples/svelte-chat/src/routes/+layout.svelte new file mode 100644 index 000000000..d701e67c8 --- /dev/null +++ b/examples/svelte-chat/src/routes/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/examples/svelte-chat/src/routes/+layout.ts b/examples/svelte-chat/src/routes/+layout.ts new file mode 100644 index 000000000..a3d15781a --- /dev/null +++ b/examples/svelte-chat/src/routes/+layout.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/examples/svelte-chat/src/routes/+page.svelte b/examples/svelte-chat/src/routes/+page.svelte new file mode 100644 index 000000000..a8af7d126 --- /dev/null +++ b/examples/svelte-chat/src/routes/+page.svelte @@ -0,0 +1,71 @@ + + +
+ + +
+ {#if chat.messages.length === 0} + + {:else} +
+ {#each chat.messages as message, i} + {#if message.role === "user"} + + {:else if message.role === "assistant"} + + {/if} + {/each} + + {#if isLoading && (chat.messages.length === 0 || chat.messages[chat.messages.length - 1]?.role === "user")} + + {/if} + +
+
+ {/if} +
+ + chat.stop()} /> +
diff --git a/examples/svelte-chat/src/routes/AssistantMessage.svelte b/examples/svelte-chat/src/routes/AssistantMessage.svelte new file mode 100644 index 000000000..57a8b068f --- /dev/null +++ b/examples/svelte-chat/src/routes/AssistantMessage.svelte @@ -0,0 +1,71 @@ + + +
+
+ AI +
+
+ {#each toolParts as tp} + {@const state = (tp as any).state} + {@const done = state === "output-available"} +
+ {#if done} + + + + {:else} +
+ {/if} + {getToolName(tp)} +
+ {/each} + {#if textContent} + + {/if} +
+
diff --git a/examples/svelte-chat/src/routes/ChatHeader.svelte b/examples/svelte-chat/src/routes/ChatHeader.svelte new file mode 100644 index 000000000..86707b508 --- /dev/null +++ b/examples/svelte-chat/src/routes/ChatHeader.svelte @@ -0,0 +1,11 @@ + + +
+

OpenUI Svelte Chat

+

+ Powered by @openuidev/svelte-lang & Vercel AI SDK +

+
diff --git a/examples/svelte-chat/src/routes/ChatInput.svelte b/examples/svelte-chat/src/routes/ChatInput.svelte new file mode 100644 index 000000000..2e93f3b4b --- /dev/null +++ b/examples/svelte-chat/src/routes/ChatInput.svelte @@ -0,0 +1,55 @@ + + +
+
+ + {#if isLoading} + + {:else} + + {/if} +
+
diff --git a/examples/svelte-chat/src/routes/LoadingIndicator.svelte b/examples/svelte-chat/src/routes/LoadingIndicator.svelte new file mode 100644 index 000000000..07d1ab66c --- /dev/null +++ b/examples/svelte-chat/src/routes/LoadingIndicator.svelte @@ -0,0 +1,19 @@ + + +
+
+ AI +
+
+
+
+
+
+
diff --git a/examples/svelte-chat/src/routes/UserMessage.svelte b/examples/svelte-chat/src/routes/UserMessage.svelte new file mode 100644 index 000000000..bb999345b --- /dev/null +++ b/examples/svelte-chat/src/routes/UserMessage.svelte @@ -0,0 +1,19 @@ + + +
+
+ {#each parts as part} + {#if part.type === "text"} +

{part.text}

+ {/if} + {/each} +
+
diff --git a/examples/svelte-chat/src/routes/WelcomeScreen.svelte b/examples/svelte-chat/src/routes/WelcomeScreen.svelte new file mode 100644 index 000000000..c3bb760a6 --- /dev/null +++ b/examples/svelte-chat/src/routes/WelcomeScreen.svelte @@ -0,0 +1,29 @@ + + +
+
+

+ Welcome to OpenUI Chat +

+

+ Ask anything — responses are rendered as structured UI components. +

+
+
+ {#each starters as starter} + + {/each} +
+
diff --git a/examples/svelte-chat/src/routes/api/chat/+server.ts b/examples/svelte-chat/src/routes/api/chat/+server.ts new file mode 100644 index 000000000..6c0dc1620 --- /dev/null +++ b/examples/svelte-chat/src/routes/api/chat/+server.ts @@ -0,0 +1,23 @@ +import { env } from "$env/dynamic/private"; +import { library, promptOptions } from "$lib/library"; +import { tools } from "$lib/tools"; +import { createOpenAI } from "@ai-sdk/openai"; +import { convertToModelMessages, stepCountIs, streamText } from "ai"; + +const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY ?? "" }); + +const systemPrompt = library.prompt(promptOptions); + +export async function POST({ request }: { request: Request }) { + const { messages } = await request.json(); + + const result = streamText({ + model: openai("gpt-5.4"), + system: systemPrompt, + messages: await convertToModelMessages(messages), + tools, + stopWhen: stepCountIs(5), + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/examples/svelte-chat/svelte.config.js b/examples/svelte-chat/svelte.config.js new file mode 100644 index 000000000..f21eeff58 --- /dev/null +++ b/examples/svelte-chat/svelte.config.js @@ -0,0 +1,9 @@ +import adapter from "@sveltejs/adapter-auto"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + }, +}; diff --git a/examples/svelte-chat/tsconfig.json b/examples/svelte-chat/tsconfig.json new file mode 100644 index 000000000..e2018a00b --- /dev/null +++ b/examples/svelte-chat/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/examples/svelte-chat/vite.config.ts b/examples/svelte-chat/vite.config.ts new file mode 100644 index 000000000..a23082f45 --- /dev/null +++ b/examples/svelte-chat/vite.config.ts @@ -0,0 +1,10 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + ssr: { + noExternal: ["@openuidev/svelte-lang"], + }, +}); diff --git a/packages/lang-core/eslint.config.cjs b/packages/lang-core/eslint.config.cjs new file mode 100644 index 000000000..3f9a9e7c4 --- /dev/null +++ b/packages/lang-core/eslint.config.cjs @@ -0,0 +1,72 @@ +const tseslint = require("@typescript-eslint/eslint-plugin"); +const typescript = require("@typescript-eslint/parser"); +const prettier = require("eslint-config-prettier"); +const unusedImports = require("eslint-plugin-unused-imports"); +const eslintPluginPrettier = require("eslint-plugin-prettier"); + +module.exports = [ + { + files: ["**/__tests__/**/*.{ts,tsx}", "**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"], + languageOptions: { + parser: typescript, + parserOptions: { + project: "./tsconfig.test.json", + sourceType: "module", + }, + }, + }, + { + files: ["**/*.{ts,tsx}"], + ignores: [ + "**/*.stories.tsx", + "**/__tests__/**/*.{ts,tsx}", + "**/*.test.{ts,tsx}", + "**/*.spec.{ts,tsx}", + ], + languageOptions: { + parser: typescript, + parserOptions: { + project: "./tsconfig.json", + sourceType: "module", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + "unused-imports": unusedImports, + prettier: eslintPluginPrettier, + }, + rules: { + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "no-undefined": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-use-before-define": [ + "error", + { + functions: false, + classes: false, + variables: false, + }, + ], + "unused-imports/no-unused-imports": "error", + "no-console": [ + "error", + { + allow: ["error", "warn", "info"], + }, + ], + ...eslintPluginPrettier.configs.recommended.rules, + }, + }, + prettier, +]; diff --git a/packages/lang-core/package.json b/packages/lang-core/package.json new file mode 100644 index 000000000..ee5e500b9 --- /dev/null +++ b/packages/lang-core/package.json @@ -0,0 +1,58 @@ +{ + "name": "@openuidev/lang-core", + "version": "0.1.0", + "description": "Framework-agnostic core for OpenUI Lang: parser, prompt generation, validation, and type definitions", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "test": "vitest run", + "build": "tsc -p .", + "watch": "tsc -p . --watch", + "lint:check": "eslint ./src", + "lint:fix": "eslint ./src --fix", + "format:fix": "prettier --write ./src", + "format:check": "prettier --check ./src", + "prepare": "pnpm run build", + "ci": "pnpm run lint:check && pnpm run format:check" + }, + "dependencies": { + "zod": "^4.0.0" + }, + "keywords": [ + "openui", + "openui-lang", + "parser", + "prompt-generation", + "validation", + "zod", + "llm", + "generative-ui", + "framework-agnostic" + ], + "homepage": "https://openui.com", + "repository": { + "type": "git", + "url": "https://github.com/thesysdev/openui.git", + "directory": "packages/lang-core" + }, + "bugs": { + "url": "https://github.com/thesysdev/openui/issues" + }, + "author": "engineering@thesys.dev", + "devDependencies": { + "vitest": "^4.0.18" + } +} diff --git a/packages/lang-core/src/index.ts b/packages/lang-core/src/index.ts new file mode 100644 index 000000000..f0696373d --- /dev/null +++ b/packages/lang-core/src/index.ts @@ -0,0 +1,22 @@ +// ── Library (framework-generic) ── +export { createLibrary, defineComponent } from "./library"; +export type { + ComponentGroup, + ComponentRenderProps, + DefinedComponent, + Library, + LibraryDefinition, + PromptOptions, + SubComponentOf, +} from "./library"; + +// ── Parser ── +export { createParser, createStreamingParser, parse } from "./parser"; +export type { LibraryJSONSchema, Parser, StreamParser } from "./parser"; +export { generatePrompt } from "./parser/prompt"; +export { BuiltinActionType } from "./parser/types"; +export type { ActionEvent, ElementNode, ParseResult, ValidationErrorCode } from "./parser/types"; + +// ── Validation ── +export { builtInValidators, parseRules, parseStructuredRules, validate } from "./utils/validation"; +export type { ParsedRule, ValidatorFn } from "./utils/validation"; diff --git a/packages/lang-core/src/library.ts b/packages/lang-core/src/library.ts new file mode 100644 index 000000000..8d0a4b18c --- /dev/null +++ b/packages/lang-core/src/library.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; +import { generatePrompt } from "./parser/prompt"; + +// ─── Sub-component type ────────────────────────────────────────────────────── + +/** + * Runtime shape of a parsed sub-component element as seen by parent renderers. + */ +export type SubComponentOf

= { + type: "element"; + typeName: string; + props: P; + partial: boolean; +}; + +// ─── Renderer types (framework-generic) ────────────────────────────────────── + +/** + * The props passed to every component renderer. + * + * Framework adapters narrow `RenderNode`: + * - React: RenderNode = ReactNode + * - Svelte: RenderNode = Snippet<[unknown]> + * - Vue: RenderNode = VNode + */ +export interface ComponentRenderProps

, RenderNode = unknown> { + props: P; + renderNode: (value: unknown) => RenderNode; +} + +// ─── DefinedComponent (framework-generic) ──────────────────────────────────── + +/** + * A fully defined component. The `C` parameter represents the + * framework-specific component/renderer type. lang-core never + * inspects this value — it is stored opaquely and consumed + * by the framework adapter's Renderer. + */ +export interface DefinedComponent = z.ZodObject, C = unknown> { + name: string; + props: T; + description: string; + component: C; + /** Use in parent schemas: `z.array(ChildComponent.ref)` */ + ref: z.ZodType>>; +} + +/** + * Define a component with name, schema, description, and renderer. + * Registers the Zod schema globally and returns a `.ref` for parent schemas. + */ +export function defineComponent, C>(config: { + name: string; + props: T; + description: string; + component: C; +}): DefinedComponent { + (config.props as any).register(z.globalRegistry, { id: config.name }); + return { + ...config, + ref: config.props as unknown as z.ZodType>>, + }; +} + +// ─── Groups & Prompt ────────────────────────────────────────────────────────── + +export interface ComponentGroup { + name: string; + components: string[]; + notes?: string[]; +} + +export interface PromptOptions { + preamble?: string; + additionalRules?: string[]; + examples?: string[]; +} + +// ─── Library ────────────────────────────────────────────────────────────────── + +export interface Library { + readonly components: Record>; + readonly componentGroups: ComponentGroup[] | undefined; + readonly root: string | undefined; + + prompt(options?: PromptOptions): string; + toJSONSchema(): object; +} + +export interface LibraryDefinition { + components: DefinedComponent[]; + componentGroups?: ComponentGroup[]; + root?: string; +} + +/** + * Create a component library from an array of defined components. + */ +export function createLibrary(input: LibraryDefinition): Library { + const componentsRecord: Record> = {}; + for (const comp of input.components) { + if (!z.globalRegistry.has(comp.props)) { + comp.props.register(z.globalRegistry, { id: comp.name }); + } + componentsRecord[comp.name] = comp; + } + + if (input.root && !componentsRecord[input.root]) { + const available = Object.keys(componentsRecord).join(", "); + throw new Error( + `[createLibrary] Root component "${input.root}" was not found in components. Available components: ${available}`, + ); + } + + const library: Library = { + components: componentsRecord, + componentGroups: input.componentGroups, + root: input.root, + + prompt(options?: PromptOptions): string { + return generatePrompt(library, options); + }, + + toJSONSchema(): object { + const combinedSchema = z.object( + Object.fromEntries(Object.entries(componentsRecord).map(([k, v]) => [k, v.props])) as any, + ); + return z.toJSONSchema(combinedSchema); + }, + }; + + return library; +} diff --git a/packages/react-lang/src/parser/__tests__/parser.test.ts b/packages/lang-core/src/parser/__tests__/parser.test.ts similarity index 98% rename from packages/react-lang/src/parser/__tests__/parser.test.ts rename to packages/lang-core/src/parser/__tests__/parser.test.ts index bd91ede82..1ea6cfb7d 100644 --- a/packages/react-lang/src/parser/__tests__/parser.test.ts +++ b/packages/lang-core/src/parser/__tests__/parser.test.ts @@ -27,7 +27,7 @@ const schema: ParamMap = new Map([ // ── Helpers ─────────────────────────────────────────────────────────────────── const errors = (input: string) => parse(input, schema).meta.errors; -const codes = (input: string) => errors(input).map((e) => e.code); +const codes = (input: string) => errors(input).map((e: { code: string }) => e.code); // ── unknown-component ──────────────────────────────────────────────────────── diff --git a/packages/react-lang/src/parser/index.ts b/packages/lang-core/src/parser/index.ts similarity index 100% rename from packages/react-lang/src/parser/index.ts rename to packages/lang-core/src/parser/index.ts diff --git a/packages/react-lang/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts similarity index 100% rename from packages/react-lang/src/parser/parser.ts rename to packages/lang-core/src/parser/parser.ts diff --git a/packages/react-lang/src/parser/prompt.ts b/packages/lang-core/src/parser/prompt.ts similarity index 100% rename from packages/react-lang/src/parser/prompt.ts rename to packages/lang-core/src/parser/prompt.ts diff --git a/packages/react-lang/src/parser/types.ts b/packages/lang-core/src/parser/types.ts similarity index 100% rename from packages/react-lang/src/parser/types.ts rename to packages/lang-core/src/parser/types.ts diff --git a/packages/react-lang/src/utils/validation.ts b/packages/lang-core/src/utils/validation.ts similarity index 100% rename from packages/react-lang/src/utils/validation.ts rename to packages/lang-core/src/utils/validation.ts diff --git a/packages/lang-core/tsconfig.json b/packages/lang-core/tsconfig.json new file mode 100644 index 000000000..00124d5b9 --- /dev/null +++ b/packages/lang-core/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"], + "compilerOptions": { + "moduleResolution": "bundler", + "module": "ESNext", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": false, + "noImplicitReturns": false, + "noImplicitOverride": false + } +} diff --git a/packages/lang-core/tsconfig.test.json b/packages/lang-core/tsconfig.test.json new file mode 100644 index 000000000..60b3001d5 --- /dev/null +++ b/packages/lang-core/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/react-lang/package.json b/packages/react-lang/package.json index 08c1c5f5c..09904e67e 100644 --- a/packages/react-lang/package.json +++ b/packages/react-lang/package.json @@ -55,6 +55,7 @@ }, "author": "engineering@thesys.dev", "dependencies": { + "@openuidev/lang-core": "workspace:^", "zod": "^4.0.0" }, "peerDependencies": { diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx index ef5997e48..fdd8c6eb9 100644 --- a/packages/react-lang/src/Renderer.tsx +++ b/packages/react-lang/src/Renderer.tsx @@ -1,8 +1,8 @@ +import type { ActionEvent, ElementNode, ParseResult } from "@openuidev/lang-core"; import React, { Component, Fragment, useEffect } from "react"; import { OpenUIContext, useOpenUI, useRenderNode } from "./context"; import { useOpenUIState } from "./hooks/useOpenUIState"; import type { ComponentRenderer, Library } from "./library"; -import type { ActionEvent, ElementNode, ParseResult } from "./parser/types"; export interface RendererProps { /** Raw response text (openui-lang code). */ diff --git a/packages/react-lang/src/hooks/useFormValidation.ts b/packages/react-lang/src/hooks/useFormValidation.ts index dfaea6708..073448b36 100644 --- a/packages/react-lang/src/hooks/useFormValidation.ts +++ b/packages/react-lang/src/hooks/useFormValidation.ts @@ -1,5 +1,5 @@ +import { validate, type ParsedRule } from "@openuidev/lang-core"; import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; -import { validate, type ParsedRule } from "../utils/validation"; export interface FormValidationContextValue { errors: Record; diff --git a/packages/react-lang/src/hooks/useOpenUIState.ts b/packages/react-lang/src/hooks/useOpenUIState.ts index 59099fa9a..f10875088 100644 --- a/packages/react-lang/src/hooks/useOpenUIState.ts +++ b/packages/react-lang/src/hooks/useOpenUIState.ts @@ -1,10 +1,13 @@ +import { + BuiltinActionType, + createParser, + type ActionEvent, + type ParseResult, +} from "@openuidev/lang-core"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { OpenUIContextValue } from "../context"; import type { Library } from "../library"; -import { createParser } from "../parser/parser"; -import type { ActionEvent, ParseResult } from "../parser/types"; -import { BuiltinActionType } from "../parser/types"; export interface UseOpenUIStateOptions { response: string | null; diff --git a/packages/react-lang/src/index.ts b/packages/react-lang/src/index.ts index 662d02475..9ef8bdce5 100644 --- a/packages/react-lang/src/index.ts +++ b/packages/react-lang/src/index.ts @@ -16,11 +16,11 @@ export { Renderer } from "./Renderer"; export type { RendererProps } from "./Renderer"; // openui-lang action types -export { BuiltinActionType } from "./parser/types"; -export type { ActionEvent, ElementNode, ParseResult } from "./parser/types"; +export { BuiltinActionType } from "@openuidev/lang-core"; +export type { ActionEvent, ElementNode, ParseResult } from "@openuidev/lang-core"; // openui-lang parser (server-side use) -export { createParser, createStreamingParser, type LibraryJSONSchema } from "./parser"; +export { createParser, createStreamingParser, type LibraryJSONSchema } from "@openuidev/lang-core"; // openui-lang context hooks (for use inside component renderers) export { @@ -42,5 +42,10 @@ export { } from "./hooks/useFormValidation"; export type { FormValidationContextValue } from "./hooks/useFormValidation"; -export { builtInValidators, parseRules, parseStructuredRules, validate } from "./utils/validation"; -export type { ParsedRule, ValidatorFn } from "./utils/validation"; +export { + builtInValidators, + parseRules, + parseStructuredRules, + validate, +} from "@openuidev/lang-core"; +export type { ParsedRule, ValidatorFn } from "@openuidev/lang-core"; diff --git a/packages/react-lang/src/library.ts b/packages/react-lang/src/library.ts index 7c877adb7..e0cdc8109 100644 --- a/packages/react-lang/src/library.ts +++ b/packages/react-lang/src/library.ts @@ -1,42 +1,34 @@ +import { + createLibrary as coreCreateLibrary, + defineComponent as coreDefineComponent, + type DefinedComponent as CoreDefinedComponent, + type Library as CoreLibrary, + type LibraryDefinition as CoreLibraryDefinition, + type ComponentRenderProps as CoreRenderProps, +} from "@openuidev/lang-core"; import type { ReactNode } from "react"; import { z } from "zod"; -import { generatePrompt } from "./parser/prompt"; -// ─── Sub-component type ────────────────────────────────────────────────────── +// Re-export framework-agnostic types unchanged +export type { ComponentGroup, PromptOptions, SubComponentOf } from "@openuidev/lang-core"; -/** - * Runtime shape of a parsed sub-component element as seen by parent renderers. - */ -export type SubComponentOf

= { - type: "element"; - typeName: string; - props: P; - partial: boolean; -}; +// ─── React-specific types ─────────────────────────────────────────────────── -// ─── Renderer types ─────────────────────────────────────────────────────────── - -export interface ComponentRenderProps

> { - props: P; - renderNode: (value: unknown) => ReactNode; -} +export interface ComponentRenderProps

> + extends CoreRenderProps {} export type ComponentRenderer

> = React.FC>; -// ─── DefinedComponent ───────────────────────────────────────────────────────── +export type DefinedComponent = z.ZodObject> = CoreDefinedComponent< + T, + ComponentRenderer> +>; -/** - * A fully defined component with name, schema, description, renderer, - * and a `.ref` for type-safe cross-referencing in parent schemas. - */ -export interface DefinedComponent = z.ZodObject> { - name: string; - props: T; - description: string; - component: ComponentRenderer>; - /** Use in parent schemas: `z.array(ChildComponent.ref)` */ - ref: z.ZodType>>; -} +export type Library = CoreLibrary>; + +export type LibraryDefinition = CoreLibraryDefinition>; + +// ─── defineComponent (React) ──────────────────────────────────────────────── /** * Define a component with name, schema, description, and renderer. @@ -67,56 +59,10 @@ export function defineComponent>(config: { description: string; component: ComponentRenderer>; }): DefinedComponent { - (config.props as any).register(z.globalRegistry, { id: config.name }); - return { - ...config, - ref: config.props as unknown as z.ZodType>>, - }; + return coreDefineComponent>>(config); } -// ─── Groups & Prompt ────────────────────────────────────────────────────────── - -export interface ComponentGroup { - name: string; - components: string[]; - notes?: string[]; -} - -export interface PromptOptions { - preamble?: string; - additionalRules?: string[]; - examples?: string[]; -} - -// ─── Library ────────────────────────────────────────────────────────────────── - -export interface Library { - readonly components: Record; - readonly componentGroups: ComponentGroup[] | undefined; - readonly root: string | undefined; - - prompt(options?: PromptOptions): string; - /** - * Returns a single, valid JSON Schema document for the entire library. - * All component schemas are in `$defs`, keyed by component name. - * Sub-schemas shared across components (e.g. `Series`, `CardHeader`) are - * emitted once and referenced via `$ref` — no repetition. - * - * @example - * ```ts - * const schema = library.toJSONSchema(); - * // schema.$defs["Card"] → { properties: {...}, required: [...] } - * // schema.$defs["Series"] → { properties: {...}, required: [...] } - * ``` - */ - toJSONSchema(): object; -} - -export interface LibraryDefinition { - components: DefinedComponent[]; - componentGroups?: ComponentGroup[]; - root?: string; -} +// ─── createLibrary (React) ────────────────────────────────────────────────── /** * Create a component library from an array of defined components. @@ -130,40 +76,5 @@ export interface LibraryDefinition { * ``` */ export function createLibrary(input: LibraryDefinition): Library { - const componentsRecord: Record = {}; - for (const comp of input.components) { - if (!z.globalRegistry.has(comp.props)) { - comp.props.register(z.globalRegistry, { id: comp.name }); - } - componentsRecord[comp.name] = comp; - } - - if (input.root && !componentsRecord[input.root]) { - const available = Object.keys(componentsRecord).join(", "); - throw new Error( - `[createLibrary] Root component "${input.root}" was not found in components. Available components: ${available}`, - ); - } - - const library: Library = { - components: componentsRecord, - componentGroups: input.componentGroups, - root: input.root, - - prompt(options?: PromptOptions): string { - return generatePrompt(library, options); - }, - - toJSONSchema(): object { - // Build one combined z.object so z.toJSONSchema emits all component - // schemas into a shared $defs block — sub-schemas like CardHeader or - // Series are defined once and referenced via $ref everywhere else. - const combinedSchema = z.object( - Object.fromEntries(Object.entries(componentsRecord).map(([k, v]) => [k, v.props])) as any, - ); - return z.toJSONSchema(combinedSchema); - }, - }; - - return library; + return coreCreateLibrary>(input) as Library; } diff --git a/packages/react-lang/src/utils/index.ts b/packages/react-lang/src/utils/index.ts deleted file mode 100644 index 6495f6152..000000000 --- a/packages/react-lang/src/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { builtInValidators, parseRules, parseStructuredRules, validate } from "./validation"; -export type { ValidatorFn } from "./validation"; diff --git a/packages/svelte-lang/README.md b/packages/svelte-lang/README.md new file mode 100644 index 000000000..8fbee62e5 --- /dev/null +++ b/packages/svelte-lang/README.md @@ -0,0 +1,258 @@ +# @openuidev/svelte-lang + +Svelte 5 runtime for [OpenUI](https://openui.com) — define component libraries, generate model prompts, and render structured UI from streaming LLM output. + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/thesysdev/openui/blob/main/LICENSE) + +## Install + +```bash +npm install @openuidev/svelte-lang +# or +pnpm add @openuidev/svelte-lang +``` + +**Peer dependencies:** `svelte >=5.0.0` + +## Overview + +`@openuidev/svelte-lang` provides three core capabilities: + +1. **Define components** — Use `defineComponent` and `createLibrary` to declare what the model is allowed to generate, with typed props via Zod schemas. +2. **Generate prompts** — Call `library.prompt()` to produce a system prompt that instructs the model how to emit OpenUI Lang output. +3. **Render output** — Use `` to parse and progressively render streamed OpenUI Lang into Svelte components. + +## Quick Start + +### 1. Define a component + +```svelte + + + +

+ Hello, {props.name}! +
+``` + +```ts +import { defineComponent } from "@openuidev/svelte-lang"; +import { z } from "zod"; +import Greeting from "./Greeting.svelte"; + +const GreetingDef = defineComponent({ + name: "Greeting", + description: "Displays a greeting message", + props: z.object({ + name: z.string().describe("The person's name"), + mood: z.enum(["happy", "excited"]).optional().describe("Tone of the greeting"), + }), + component: Greeting, +}); +``` + +### 2. Create a library + +```ts +import { createLibrary } from "@openuidev/svelte-lang"; + +const library = createLibrary({ + components: [GreetingDef, CardDef, TableDef /* ... */], + root: "Card", // optional default root component +}); +``` + +### 3. Generate a system prompt + +```ts +const systemPrompt = library.prompt({ + preamble: "You are a helpful assistant.", + additionalRules: ["Always greet the user by name."], + examples: ['User: Hi\n\nroot = Greeting("Alice", "happy")'], +}); +``` + +### 4. Render streamed output + +```svelte + + + console.log("Action:", event)} +/> +``` + +## API Reference + +### Component Definition + +| Export | Description | +| :-------------------------- | :----------------------------------------------------------------------------------------- | +| `defineComponent(config)` | Define a single component with a name, Zod props schema, description, and Svelte component | +| `createLibrary(definition)` | Create a library from an array of defined components | + +### Rendering + +| Export | Description | +| :--------- | :---------------------------------------------------------- | +| `Renderer` | Svelte component that parses and renders OpenUI Lang output | + +**`RendererProps`:** + +| Prop | Type | Description | +| :-------------- | :-------------------------------------- | :---------------------------------------------------------------- | +| `response` | `string \| null` | Raw OpenUI Lang text from the model | +| `library` | `Library` | Component library from `createLibrary()` | +| `isStreaming` | `boolean` | Whether the model is still streaming (disables form interactions) | +| `onAction` | `(event: ActionEvent) => void` | Callback when a component triggers an action | +| `onStateUpdate` | `(state: Record) => void` | Callback when form field values change | +| `initialState` | `Record` | Initial form state for hydration | +| `onParseResult` | `(result: ParseResult \| null) => void` | Callback when the parse result changes | + +### Children Rendering + +Svelte components receive `renderNode` as a **snippet prop** (not via context). Use it to render child element nodes: + +```svelte + + +
+ {#if props.children} + {@render renderNode(props.children)} + {/if} +
+``` + +### Parser (Server-Side) + +| Export | Description | +| :------------------------------ | :----------------------------------------------------- | +| `createParser(schema)` | Create a one-shot parser for complete OpenUI Lang text | +| `createStreamingParser(schema)` | Create an incremental parser for streaming input | + +The streaming parser exposes two methods: + +| Method | Description | +| :------------ | :---------------------------------------------------- | +| `push(chunk)` | Feed the next chunk; returns the latest `ParseResult` | +| `getResult()` | Get the latest result without consuming new data | + +After the stream ends, check `meta.unresolved` for any identifiers that were referenced but never defined. During streaming these are expected (forward refs) and are not treated as errors. + +#### Errors + +`ParseResult.meta.errors` contains structured `OpenUIError` objects. Each error has a `type` discriminant (currently always `"validation"`) and a `code` for consumer-side filtering: + +| Code | Meaning | +| :------------------ | :-------------------------------------------------- | +| `missing-required` | Required prop absent with no default | +| `null-required` | Required prop explicitly null with no default | +| `unknown-component` | Component name not found in the library schema | +| `excess-args` | More positional args passed than the schema defines | + +Errors do not affect rendering — the parser stays permissive and renders what it can: + +```ts +const result = parser.parse(output); +const critical = result.meta.errors.filter((e) => e.code === "unknown-component"); +``` + +### Context Getters + +Use these inside component renderers to interact with the rendering context: + +| Function | Returns | Description | +| :------------------------- | :-------------------- | :----------------------------------------------------------------------------- | +| `getOpenUIContext()` | `OpenUIContextValue` | Access the full context object (library, streaming state, field accessors) | +| `getIsStreaming()` | `() => boolean` | Returns a getter for the streaming state — call it reactively: `isStreaming()` | +| `getTriggerAction()` | `Function` | Trigger an action event | +| `getGetFieldValue()` | `Function` | Get a form field's current value | +| `getSetFieldValue()` | `Function` | Set a form field's value | +| `getFormName()` | `string \| undefined` | Get the current form's name | +| `useSetDefaultValue(opts)` | `void` | Set a field's default value once streaming completes | + +### Form Validation + +| Export | Description | +| :--------------------------- | :---------------------------------------------------- | +| `getFormValidation()` | Access form validation state | +| `createFormValidation()` | Create a form validation context | +| `setFormValidationContext()` | Provide validation context to child components | +| `validate(value, rules)` | Run validation rules against a value | +| `builtInValidators` | Built-in validators (required, email, min, max, etc.) | +| `parseRules(rules)` | Parse a rules config object into `ParsedRule[]` | + +### Types + +```ts +import type { + // Component definition + Library, + LibraryDefinition, + DefinedComponent, + ComponentRenderer, + ComponentRenderProps, + ComponentGroup, + SubComponentOf, + PromptOptions, + + // Rendering + RendererProps, + OpenUIContextValue, + ActionConfig, + + // Parser & core + ActionEvent, + ElementNode, + ParseResult, + LibraryJSONSchema, + + // Validation + FormValidationContextValue, + ParsedRule, + ValidatorFn, +} from "@openuidev/svelte-lang"; +``` + +## JSON Schema Output + +Libraries can also produce a JSON Schema representation of their components: + +```ts +const schema = library.toJSONSchema(); +// schema.$defs["Card"] → { properties: {...}, required: [...] } +// schema.$defs["Greeting"] → { properties: {...}, required: [...] } +``` + +## Differences from React + +| Concern | `react-lang` | `svelte-lang` | +| :----------------- | :----------------------------------------------- | :----------------------------------------------- | +| Children rendering | `renderNode` function prop returning `ReactNode` | `renderNode` **snippet** (`Snippet<[unknown]>`) | +| Context access | Hooks (`useIsStreaming()`, etc.) | Getters (`getIsStreaming()`, etc.) | +| Error boundaries | Class-based, preserves last valid render | `svelte:boundary` with auto-retry on prop change | +| Reactivity | Hooks re-run on every render | Runes (`$state`, `$derived`, `$effect`) | + +## Documentation + +Full documentation, guides, and the language specification are available at **[openui.com](https://openui.com)**. + +## License + +[MIT](https://github.com/thesysdev/openui/blob/main/LICENSE) diff --git a/packages/svelte-lang/package.json b/packages/svelte-lang/package.json new file mode 100644 index 000000000..12ec22803 --- /dev/null +++ b/packages/svelte-lang/package.json @@ -0,0 +1,78 @@ +{ + "name": "@openuidev/svelte-lang", + "version": "0.1.0", + "description": "Define component libraries, generate LLM system prompts, and render streaming OpenUI Lang output in Svelte 5 — the Svelte runtime for OpenUI generative UI", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "svelte": "./dist/index.js", + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "svelte": "./dist/index.js", + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "svelte-package", + "watch": "svelte-package --watch", + "lint:check": "eslint ./src", + "lint:fix": "eslint ./src --fix", + "format:fix": "prettier --write ./src", + "format:check": "prettier --check ./src", + "prepare": "pnpm run build", + "check": "svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "ci": "pnpm run lint:check && pnpm run format:check" + }, + "keywords": [ + "openui", + "generative-ui", + "svelte", + "svelte5", + "llm", + "streaming", + "renderer", + "parser", + "ai", + "components", + "prompt-generation", + "zod", + "ui-generation", + "model-driven-ui", + "openui-lang" + ], + "homepage": "https://openui.com", + "repository": { + "type": "git", + "url": "https://github.com/thesysdev/openui.git", + "directory": "packages/svelte-lang" + }, + "bugs": { + "url": "https://github.com/thesysdev/openui/issues" + }, + "author": "engineering@thesys.dev", + "dependencies": { + "@openuidev/lang-core": "workspace:^", + "zod": "^4.0.0" + }, + "peerDependencies": { + "svelte": ">=5.0.0" + }, + "devDependencies": { + "@sveltejs/package": "^2.3.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@testing-library/svelte": "^5.2.0", + "jsdom": "^26.1.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/svelte-lang/src/__tests__/Renderer.test.ts b/packages/svelte-lang/src/__tests__/Renderer.test.ts new file mode 100644 index 000000000..846eb9d82 --- /dev/null +++ b/packages/svelte-lang/src/__tests__/Renderer.test.ts @@ -0,0 +1,127 @@ +import { render } from "@testing-library/svelte"; +import { tick } from "svelte"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import Renderer from "../lib/Renderer.svelte"; +import { createLibrary, defineComponent } from "../lib/library.js"; + +// Dummy renderer — never actually renders DOM, used for parser/callback tests +const DummyComponent = (() => null) as any; + +const TextContent = defineComponent({ + name: "TextContent", + props: z.object({ text: z.string() }), + description: "Displays text content", + component: DummyComponent, +}); + +const library = createLibrary({ + components: [TextContent], + root: "TextContent", +}); + +// openui-lang uses assignment syntax: `identifier = Component(args)` +const VALID_RESPONSE = 'root = TextContent("Hello world")'; + +// ─── Renderer ─────────────────────────────────────────────────────────────── + +describe("Renderer", () => { + it("renders without errors when response is null", () => { + const { container } = render(Renderer, { + props: { + response: null, + library, + }, + }); + + // Should render an empty container (no crash) + expect(container).toBeDefined(); + }); + + it("renders without errors when response is empty string", () => { + const { container } = render(Renderer, { + props: { + response: "", + library, + }, + }); + + expect(container).toBeDefined(); + }); + + it("calls onParseResult with null when response is null", async () => { + const onParseResult = vi.fn(); + + render(Renderer, { + props: { + response: null, + library, + onParseResult, + }, + }); + + // $effect runs asynchronously — flush microtasks + await tick(); + + expect(onParseResult).toHaveBeenCalledWith(null); + }); + + it("calls onParseResult with a ParseResult when given valid openui-lang", async () => { + const onParseResult = vi.fn(); + + render(Renderer, { + props: { + response: VALID_RESPONSE, + library, + onParseResult, + }, + }); + + await tick(); + + expect(onParseResult).toHaveBeenCalled(); + const result = onParseResult.mock.calls[onParseResult.mock.calls.length - 1]![0]; + expect(result).not.toBeNull(); + expect(result.root).toBeDefined(); + expect(result.root).not.toBeNull(); + }); + + it("parse result contains the correct component typeName", async () => { + const onParseResult = vi.fn(); + + render(Renderer, { + props: { + response: VALID_RESPONSE, + library, + onParseResult, + }, + }); + + await tick(); + + const result = onParseResult.mock.calls[onParseResult.mock.calls.length - 1]![0]; + expect(result?.root?.typeName).toBe("TextContent"); + }); + + it("defaults isStreaming to false", () => { + // Should not throw when isStreaming is omitted + const { container } = render(Renderer, { + props: { + response: null, + library, + }, + }); + expect(container).toBeDefined(); + }); + + it("accepts isStreaming prop without errors", () => { + const { container } = render(Renderer, { + props: { + response: null, + library, + isStreaming: true, + }, + }); + expect(container).toBeDefined(); + }); +}); diff --git a/packages/svelte-lang/src/__tests__/library.test.ts b/packages/svelte-lang/src/__tests__/library.test.ts new file mode 100644 index 000000000..085abaf61 --- /dev/null +++ b/packages/svelte-lang/src/__tests__/library.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { createLibrary, defineComponent } from "../lib/library.js"; + +// Dummy renderer — never actually called in these tests +const DummyComponent = (() => null) as any; + +function makeComponent(name: string, schema: z.ZodObject, description: string) { + return defineComponent({ + name, + props: schema, + description, + component: DummyComponent, + }); +} + +// ─── defineComponent ──────────────────────────────────────────────────────── + +describe("defineComponent", () => { + it("returns an object with name, props, description, component, and ref", () => { + const schema = z.object({ label: z.string() }); + const result = defineComponent({ + name: "Badge", + props: schema, + description: "A simple badge", + component: DummyComponent, + }); + + expect(result.name).toBe("Badge"); + expect(result.props).toBe(schema); + expect(result.description).toBe("A simple badge"); + expect(result.component).toBe(DummyComponent); + expect(result.ref).toBeDefined(); + }); + + it("registers the Zod schema in the global registry", () => { + const schema = z.object({ title: z.string() }); + const comp = defineComponent({ + name: "Heading", + props: schema, + description: "A heading element", + component: DummyComponent, + }); + + // After defineComponent, the schema should be in the global registry + expect(z.globalRegistry.has(comp.props)).toBe(true); + }); +}); + +// ─── createLibrary ────────────────────────────────────────────────────────── + +describe("createLibrary", () => { + const TextContent = makeComponent( + "TextContent", + z.object({ text: z.string() }), + "Displays text content", + ); + + const Container = makeComponent( + "Container", + z.object({ title: z.string() }), + "A container with a title", + ); + + it("creates a library with a components record", () => { + const lib = createLibrary({ components: [TextContent, Container] }); + + expect(lib.components.TextContent).toBe(TextContent); + expect(lib.components.Container).toBe(Container); + expect(Object.keys(lib.components)).toHaveLength(2); + }); + + it("stores root and componentGroups", () => { + const lib = createLibrary({ + components: [TextContent], + root: "TextContent", + componentGroups: [{ name: "Display", components: ["TextContent"] }], + }); + + expect(lib.root).toBe("TextContent"); + expect(lib.componentGroups).toEqual([{ name: "Display", components: ["TextContent"] }]); + }); + + it("throws if root component is not found in components", () => { + expect(() => + createLibrary({ + components: [TextContent], + root: "NonExistent", + }), + ).toThrow(/Root component "NonExistent" was not found/); + }); + + it("prompt() returns a string containing component descriptions", () => { + const lib = createLibrary({ + components: [TextContent, Container], + root: "TextContent", + }); + + const prompt = lib.prompt(); + expect(typeof prompt).toBe("string"); + expect(prompt.length).toBeGreaterThan(0); + // The prompt should mention at least one component name + expect(prompt).toContain("TextContent"); + }); + + it("toJSONSchema() returns an object with $defs", () => { + const lib = createLibrary({ + components: [TextContent], + root: "TextContent", + }); + + const schema = lib.toJSONSchema() as Record; + expect(schema).toBeDefined(); + expect(typeof schema).toBe("object"); + expect(schema["$defs"]).toBeDefined(); + expect(typeof schema["$defs"]).toBe("object"); + }); + + it("works without a root component", () => { + const lib = createLibrary({ components: [TextContent] }); + + expect(lib.root).toBeUndefined(); + // prompt/schema should still work + expect(typeof lib.prompt()).toBe("string"); + expect(lib.toJSONSchema()).toBeDefined(); + }); +}); diff --git a/packages/svelte-lang/src/__tests__/validation.test.ts b/packages/svelte-lang/src/__tests__/validation.test.ts new file mode 100644 index 000000000..4b319dac0 --- /dev/null +++ b/packages/svelte-lang/src/__tests__/validation.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { + builtInValidators, + parseRules, + parseStructuredRules, + validate, +} from "../lib/validation.svelte.js"; + +// ─── builtInValidators ────────────────────────────────────────────────────── + +describe("builtInValidators", () => { + it("has all expected validators", () => { + const expected = [ + "required", + "email", + "url", + "numeric", + "min", + "max", + "minLength", + "maxLength", + "pattern", + ]; + for (const name of expected) { + expect(builtInValidators[name]).toBeDefined(); + expect(typeof builtInValidators[name]).toBe("function"); + } + }); +}); + +// ─── parseRules ───────────────────────────────────────────────────────────── + +describe("parseRules", () => { + it("parses simple rule strings into ParsedRule objects", () => { + const result = parseRules(["required", "email"]); + expect(result).toEqual([{ type: "required" }, { type: "email" }]); + }); + + it("parses rules with numeric arguments", () => { + const result = parseRules(["min:8", "maxLength:100"]); + expect(result).toEqual([ + { type: "min", arg: 8 }, + { type: "maxLength", arg: 100 }, + ]); + }); + + it("parses rules with string arguments", () => { + const result = parseRules(["pattern:^[a-z]+"]); + expect(result).toEqual([{ type: "pattern", arg: "^[a-z]+" }]); + }); + + it("returns an empty array for non-array input", () => { + expect(parseRules(null)).toEqual([]); + expect(parseRules(undefined)).toEqual([]); + expect(parseRules("required")).toEqual([]); + }); + + it("filters out non-string entries", () => { + const result = parseRules(["required", 42, null, "email"]); + expect(result).toEqual([{ type: "required" }, { type: "email" }]); + }); +}); + +// ─── validate ─────────────────────────────────────────────────────────────── + +describe("validate", () => { + it("returns error string for required on empty value", () => { + const rules = [{ type: "required" }]; + const error = validate("", rules); + expect(error).toBe("This field is required"); + }); + + it("returns undefined when valid value passes required", () => { + const rules = [{ type: "required" }]; + expect(validate("hello", rules)).toBeUndefined(); + }); + + it("validates email format", () => { + const rules = [{ type: "email" }]; + expect(validate("bad-email", rules)).toBe("Please enter a valid email"); + expect(validate("test@email.com", rules)).toBeUndefined(); + }); + + it("validates min/max with numeric arguments", () => { + expect(validate(3, [{ type: "min", arg: 5 }])).toBe("Must be at least 5"); + expect(validate(10, [{ type: "min", arg: 5 }])).toBeUndefined(); + expect(validate(20, [{ type: "max", arg: 10 }])).toBe("Must be no more than 10"); + expect(validate(5, [{ type: "max", arg: 10 }])).toBeUndefined(); + }); + + it("stops on first error with multiple rules", () => { + const rules = [{ type: "required" }, { type: "email" }]; + // Empty string triggers "required" first, not "email" + expect(validate("", rules)).toBe("This field is required"); + }); + + it("returns undefined when no rules match", () => { + expect(validate("anything", [{ type: "nonExistentRule" }])).toBeUndefined(); + }); +}); + +// ─── parseStructuredRules ─────────────────────────────────────────────────── + +describe("parseStructuredRules", () => { + it("parses an object of rules into ParsedRule array", () => { + const result = parseStructuredRules({ required: true, minLength: 5 }); + expect(result).toContainEqual({ type: "required" }); + expect(result).toContainEqual({ type: "minLength", arg: 5 }); + }); + + it("skips false/undefined/null values", () => { + const result = parseStructuredRules({ + required: true, + email: false, + max: undefined, + min: null, + }); + expect(result).toEqual([{ type: "required" }]); + }); + + it("returns empty array for non-object input", () => { + expect(parseStructuredRules(null)).toEqual([]); + expect(parseStructuredRules(undefined)).toEqual([]); + expect(parseStructuredRules([])).toEqual([]); + expect(parseStructuredRules("string")).toEqual([]); + }); +}); diff --git a/packages/svelte-lang/src/lib/RenderNode.svelte b/packages/svelte-lang/src/lib/RenderNode.svelte new file mode 100644 index 000000000..5517c2026 --- /dev/null +++ b/packages/svelte-lang/src/lib/RenderNode.svelte @@ -0,0 +1,66 @@ + + +{#if node && Comp} + + + + {#snippet failed()}{/snippet} + +{/if} diff --git a/packages/svelte-lang/src/lib/Renderer.svelte b/packages/svelte-lang/src/lib/Renderer.svelte new file mode 100644 index 000000000..32275a7d0 --- /dev/null +++ b/packages/svelte-lang/src/lib/Renderer.svelte @@ -0,0 +1,162 @@ + + +{#snippet renderNode(value: unknown)} + {#if value == null} + + {:else if typeof value === "string"} + {value} + {:else if typeof value === "number" || typeof value === "boolean"} + {String(value)} + {:else if Array.isArray(value)} + {#each value as item} + {@render renderNode(item)} + {/each} + {:else if typeof value === "object" && (value as any).type === "element"} + + {/if} +{/snippet} + +{#if result?.root} + +{/if} diff --git a/packages/svelte-lang/src/lib/context.svelte.ts b/packages/svelte-lang/src/lib/context.svelte.ts new file mode 100644 index 000000000..858b7a4f9 --- /dev/null +++ b/packages/svelte-lang/src/lib/context.svelte.ts @@ -0,0 +1,153 @@ +import { getContext, setContext } from "svelte"; +import type { Library } from "./library.js"; + +// ─── Action config ─── + +export interface ActionConfig { + type?: string; + params?: Record; +} + +// ─── OpenUI context ─── + +/** + * Shared context provided by to all rendered components. + * + * Note: `renderNode` is passed as a snippet prop, NOT via context. + * This avoids the stale-closure problem and matches Svelte's snippet model. + */ +export interface OpenUIContextValue { + /** The active component library (schema + renderers). */ + library: Library; + + /** + * Trigger an action. Components call this to fire structured ActionEvents. + * + * @param userMessage Human-readable label ("Submit Application") + * @param formName Optional form name — if provided, form state for this form is included + * @param action Optional custom action config { type, params } + */ + triggerAction: (userMessage: string, formName?: string, action?: ActionConfig) => void; + + /** Whether the LLM is currently streaming content. */ + isStreaming: boolean; + + /** Get a form field value. Returns undefined if not set. */ + getFieldValue: (formName: string | undefined, name: string) => any; + + /** + * Set a form field value. + * + * @param formName The form's name prop + * @param componentType The component type (e.g. "Input", "Select", "RadioGroup") + * @param name The field's name prop + * @param value The new value + * @param shouldTriggerSaveCallback When true, persists the updated state via updateMessage. + * Text inputs should pass `false` on change and `true` on blur. + * Discrete inputs (Select, RadioGroup, etc.) should always pass `true`. + */ + setFieldValue: ( + formName: string | undefined, + componentType: string | undefined, + name: string, + value: any, + shouldTriggerSaveCallback?: boolean, + ) => void; +} + +const OPENUI_CONTEXT_KEY = Symbol("openui-context"); +const FORM_NAME_CONTEXT_KEY = Symbol("openui-form-name"); + +// ─── Context setters ─── + +export function setOpenUIContext(value: OpenUIContextValue): void { + setContext(OPENUI_CONTEXT_KEY, value); +} + +export function setFormNameContext(formName: string | undefined): void { + setContext(FORM_NAME_CONTEXT_KEY, formName); +} + +// ─── Context getters ─── + +/** + * Access the full OpenUI context. Throws if used outside a . + */ +export function getOpenUIContext(): OpenUIContextValue { + const ctx = getContext(OPENUI_CONTEXT_KEY); + if (!ctx) { + throw new Error("getOpenUIContext must be used within a component."); + } + return ctx; +} + +/** + * Get the triggerAction function for firing structured action events. + */ +export function getTriggerAction() { + return getOpenUIContext().triggerAction; +} + +/** + * Returns a getter for the streaming state. + * Use as: `const isStreaming = getIsStreaming(); ... disabled={isStreaming()}` + */ +export function getIsStreaming(): () => boolean { + const ctx = getOpenUIContext(); + return () => ctx.isStreaming; +} + +/** + * Get a form field value from the form state context. + */ +export function getGetFieldValue() { + return getOpenUIContext().getFieldValue; +} + +/** + * Get the setFieldValue function for updating form field values. + */ +export function getSetFieldValue() { + return getOpenUIContext().setFieldValue; +} + +/** + * Get the current form name (set by the nearest parent Form component). + * Returns undefined if not inside a Form. + */ +export function getFormName(): string | undefined { + return getContext(FORM_NAME_CONTEXT_KEY); +} + +// ─── Default value helper ─── + +/** + * Persists a component's default/initial value into form state once streaming + * finishes — but only if the user hasn't already set a value. + * + * Reads the current field value directly from form state inside the effect + * so that Svelte tracks it as a reactive dependency (unlike a snapshot + * parameter that would be captured once at call time). + */ +export function useSetDefaultValue({ + formName, + componentType, + name, + defaultValue, + shouldTriggerSaveCallback = false, +}: { + formName?: string; + componentType: string; + name: string; + defaultValue: any; + shouldTriggerSaveCallback?: boolean; +}): void { + const ctx = getOpenUIContext(); + + $effect(() => { + const existing = ctx.getFieldValue(formName, name); + if (!ctx.isStreaming && existing === undefined && defaultValue !== undefined) { + ctx.setFieldValue(formName, componentType, name, defaultValue, shouldTriggerSaveCallback); + } + }); +} diff --git a/packages/svelte-lang/src/lib/index.ts b/packages/svelte-lang/src/lib/index.ts new file mode 100644 index 000000000..c413e6a91 --- /dev/null +++ b/packages/svelte-lang/src/lib/index.ts @@ -0,0 +1,70 @@ +// ─── Component definition ─── + +export { createLibrary, defineComponent } from "./library.js"; +export type { + ComponentGroup, + ComponentRenderer, + ComponentRenderProps, + DefinedComponent, + Library, + LibraryDefinition, + PromptOptions, + SubComponentOf, +} from "./library.js"; + +// ─── Renderer ─── + +import type { ActionEvent, ParseResult } from "@openuidev/lang-core"; +import type { Library } from "./library.js"; + +export { default as Renderer } from "./Renderer.svelte"; + +/** Props accepted by the Renderer component. */ +export interface RendererProps { + response: string | null; + library: Library; + isStreaming?: boolean; + onAction?: (event: ActionEvent) => void; + onStateUpdate?: (state: Record) => void; + initialState?: Record; + onParseResult?: (result: ParseResult | null) => void; +} + +// ─── Context (for use inside component renderers) ─── + +export { + getFormName, + getGetFieldValue, + getIsStreaming, + getOpenUIContext, + getSetFieldValue, + getTriggerAction, + setFormNameContext, + setOpenUIContext, + useSetDefaultValue, +} from "./context.svelte.js"; +export type { ActionConfig, OpenUIContextValue } from "./context.svelte.js"; + +// ─── Form validation ─── + +export { + createFormValidation, + getFormValidation, + setFormValidationContext, +} from "./validation.svelte.js"; +export type { FormValidationContextValue } from "./validation.svelte.js"; + +export { + builtInValidators, + parseRules, + parseStructuredRules, + validate, +} from "./validation.svelte.js"; +export type { ParsedRule, ValidatorFn } from "./validation.svelte.js"; + +// ─── Re-exports from lang-core (parser, types) ─── + +export { BuiltinActionType } from "@openuidev/lang-core"; +export type { ActionEvent, ElementNode, ParseResult } from "@openuidev/lang-core"; + +export { createParser, createStreamingParser, type LibraryJSONSchema } from "@openuidev/lang-core"; diff --git a/packages/svelte-lang/src/lib/library.ts b/packages/svelte-lang/src/lib/library.ts new file mode 100644 index 000000000..93c7f4e31 --- /dev/null +++ b/packages/svelte-lang/src/lib/library.ts @@ -0,0 +1,72 @@ +import { + createLibrary as coreCreateLibrary, + defineComponent as coreDefineComponent, + type DefinedComponent as CoreDefinedComponent, + type Library as CoreLibrary, + type LibraryDefinition as CoreLibraryDefinition, +} from "@openuidev/lang-core"; +import type { Component, Snippet } from "svelte"; +import { z } from "zod"; + +// Re-export framework-agnostic types unchanged +export type { ComponentGroup, PromptOptions, SubComponentOf } from "@openuidev/lang-core"; + +// ─── Svelte-specific types ────────────────────────────────────────────────── + +export interface ComponentRenderProps

> { + props: P; + renderNode: Snippet<[unknown]>; +} + +export type ComponentRenderer

> = Component>; + +export type DefinedComponent = z.ZodObject> = CoreDefinedComponent< + T, + ComponentRenderer> +>; + +export type Library = CoreLibrary>; + +export type LibraryDefinition = CoreLibraryDefinition>; + +// ─── defineComponent (Svelte) ─────────────────────────────────────────────── + +/** + * Define a component with name, schema, description, and renderer. + * Registers the Zod schema globally and returns a `.ref` for parent schemas. + * + * @example + * ```ts + * const TabItem = defineComponent({ + * name: "TabItem", + * props: z.object({ value: z.string(), trigger: z.string(), content: z.array(ContentChildUnion) }), + * description: "Tab panel", + * component: TabItemRenderer, + * }); + * ``` + */ +export function defineComponent>(config: { + name: string; + props: T; + description: string; + component: ComponentRenderer>; +}): DefinedComponent { + return coreDefineComponent>>(config); +} + +// ─── createLibrary (Svelte) ───────────────────────────────────────────────── + +/** + * Create a component library from an array of defined components. + * + * @example + * ```ts + * const library = createLibrary({ + * components: [TabItem, Tabs, Card], + * root: "Card", + * }); + * ``` + */ +export function createLibrary(input: LibraryDefinition): Library { + return coreCreateLibrary>(input) as Library; +} diff --git a/packages/svelte-lang/src/lib/validation.svelte.ts b/packages/svelte-lang/src/lib/validation.svelte.ts new file mode 100644 index 000000000..a05153ddc --- /dev/null +++ b/packages/svelte-lang/src/lib/validation.svelte.ts @@ -0,0 +1,101 @@ +import { + builtInValidators, + parseRules, + parseStructuredRules, + validate, + type ParsedRule, + type ValidatorFn, +} from "@openuidev/lang-core"; +import { getContext, setContext } from "svelte"; + +// ─── Re-exports from lang-core ─── + +export { builtInValidators, parseRules, parseStructuredRules, validate }; +export type { ParsedRule, ValidatorFn }; + +// ─── Form validation context ─── + +export interface FormValidationContextValue { + errors: Record; + validateField: (name: string, value: unknown, rules: ParsedRule[]) => boolean; + registerField: (name: string, rules: ParsedRule[], getValue: () => unknown) => void; + unregisterField: (name: string) => void; + validateForm: () => boolean; + clearFieldError: (name: string) => void; +} + +const FORM_VALIDATION_CONTEXT_KEY = Symbol("openui-form-validation"); + +/** + * Create a form validation instance backed by Svelte 5 $state. + * + * Call this in the Form component's `