From b961df0ed45a2b71463fd8fa448e889c95163b61 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 17 Feb 2026 14:31:11 +0100 Subject: [PATCH 01/15] create svelte-renderer --- .gitignore | 1 + package.json | 3 + packages/svelte/package.json | 70 +++ packages/svelte/src/ConfirmDialog.svelte | 98 ++++ packages/svelte/src/ElementRenderer.svelte | 114 ++++ packages/svelte/src/JsonUIProvider.svelte | 53 ++ packages/svelte/src/Renderer.svelte | 25 + .../src/RendererWithProvider.test.svelte | 54 ++ packages/svelte/src/RepeatChildren.svelte | 47 ++ packages/svelte/src/TestButton.svelte | 14 + packages/svelte/src/TestContainer.svelte | 19 + packages/svelte/src/TestText.svelte | 10 + packages/svelte/src/catalog-types.ts | 78 +++ .../svelte/src/contexts/actions.svelte.ts | 295 ++++++++++ packages/svelte/src/contexts/actions.test.ts | 141 +++++ packages/svelte/src/contexts/repeat-scope.ts | 29 + packages/svelte/src/contexts/state.svelte.ts | 64 +++ packages/svelte/src/contexts/state.test.ts | 114 ++++ .../svelte/src/contexts/validation.svelte.ts | 179 ++++++ .../svelte/src/contexts/visibility.svelte.ts | 55 ++ .../svelte/src/contexts/visibility.test.ts | 89 +++ packages/svelte/src/index.ts | 129 +++++ packages/svelte/src/registry.ts | 141 +++++ packages/svelte/src/renderer.test.ts | 193 +++++++ packages/svelte/src/schema.ts | 87 +++ packages/svelte/src/streaming.svelte.ts | 534 ++++++++++++++++++ packages/svelte/src/types.ts | 92 +++ packages/svelte/src/utils.test.ts | 375 ++++++++++++ packages/svelte/src/utils.ts | 118 ++++ packages/svelte/svelte.config.js | 9 + packages/svelte/tsconfig.json | 11 + pnpm-lock.yaml | 297 +++++++++- vitest.config.ts => vitest.config.mts | 9 +- 33 files changed, 3542 insertions(+), 5 deletions(-) create mode 100644 packages/svelte/package.json create mode 100644 packages/svelte/src/ConfirmDialog.svelte create mode 100644 packages/svelte/src/ElementRenderer.svelte create mode 100644 packages/svelte/src/JsonUIProvider.svelte create mode 100644 packages/svelte/src/Renderer.svelte create mode 100644 packages/svelte/src/RendererWithProvider.test.svelte create mode 100644 packages/svelte/src/RepeatChildren.svelte create mode 100644 packages/svelte/src/TestButton.svelte create mode 100644 packages/svelte/src/TestContainer.svelte create mode 100644 packages/svelte/src/TestText.svelte create mode 100644 packages/svelte/src/catalog-types.ts create mode 100644 packages/svelte/src/contexts/actions.svelte.ts create mode 100644 packages/svelte/src/contexts/actions.test.ts create mode 100644 packages/svelte/src/contexts/repeat-scope.ts create mode 100644 packages/svelte/src/contexts/state.svelte.ts create mode 100644 packages/svelte/src/contexts/state.test.ts create mode 100644 packages/svelte/src/contexts/validation.svelte.ts create mode 100644 packages/svelte/src/contexts/visibility.svelte.ts create mode 100644 packages/svelte/src/contexts/visibility.test.ts create mode 100644 packages/svelte/src/index.ts create mode 100644 packages/svelte/src/registry.ts create mode 100644 packages/svelte/src/renderer.test.ts create mode 100644 packages/svelte/src/schema.ts create mode 100644 packages/svelte/src/streaming.svelte.ts create mode 100644 packages/svelte/src/types.ts create mode 100644 packages/svelte/src/utils.test.ts create mode 100644 packages/svelte/src/utils.ts create mode 100644 packages/svelte/svelte.config.js create mode 100644 packages/svelte/tsconfig.json rename vitest.config.ts => vitest.config.mts (66%) diff --git a/.gitignore b/.gitignore index b32f2160..e04346c4 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ out/ build dist *.tsbuildinfo +.svelte-kit/ # Debug diff --git a/package.json b/package.json index f46779c5..089b61ea 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,10 @@ }, "devDependencies": { "@changesets/cli": "2.29.8", + "@sveltejs/vite-plugin-svelte": "^5.0.0", "@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", @@ -36,6 +38,7 @@ "prettier": "^3.7.4", "react": "^19.2.4", "react-dom": "^19.2.4", + "svelte": "^5.0.0", "turbo": "^2.7.4", "typescript": "5.9.2", "vitest": "^4.0.17" diff --git a/packages/svelte/package.json b/packages/svelte/package.json new file mode 100644 index 00000000..32df4ed6 --- /dev/null +++ b/packages/svelte/package.json @@ -0,0 +1,70 @@ +{ + "name": "@json-render/svelte", + "version": "0.6.1", + "license": "Apache-2.0", + "description": "Svelte 5 renderer for @json-render/core. JSON becomes Svelte components.", + "keywords": [ + "json", + "ui", + "svelte", + "svelte5", + "ai", + "generative-ui", + "llm", + "renderer", + "streaming", + "components", + "runes" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/vercel-labs/json-render.git", + "directory": "packages/svelte" + }, + "homepage": "https://github.com/vercel-labs/json-render#readme", + "bugs": { + "url": "https://github.com/vercel-labs/json-render/issues" + }, + "publishConfig": { + "access": "public" + }, + "type": "module", + "svelte": "./dist/index.js", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "default": "./dist/index.js" + }, + "./schema": { + "types": "./dist/schema.d.ts", + "svelte": "./dist/schema.js", + "default": "./dist/schema.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "svelte-package -i src -o dist", + "dev": "svelte-package -i src -o dist --watch", + "typecheck": "svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@json-render/core": "workspace:*" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@sveltejs/package": "^2.3.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.4.5" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } +} diff --git a/packages/svelte/src/ConfirmDialog.svelte b/packages/svelte/src/ConfirmDialog.svelte new file mode 100644 index 00000000..b354bb95 --- /dev/null +++ b/packages/svelte/src/ConfirmDialog.svelte @@ -0,0 +1,98 @@ + + + + +
+ + +
e.stopPropagation()}> +

{confirm.title}

+

{confirm.message}

+
+ + +
+
+
+ + diff --git a/packages/svelte/src/ElementRenderer.svelte b/packages/svelte/src/ElementRenderer.svelte new file mode 100644 index 00000000..d3ba299c --- /dev/null +++ b/packages/svelte/src/ElementRenderer.svelte @@ -0,0 +1,114 @@ + + +{#if isVisible && Component} + + {#if resolvedElement.repeat} + + {:else if resolvedElement.children} + {#each resolvedElement.children as childKey (childKey)} + {#if spec.elements[childKey]} + + {:else if !loading} + + {/if} + {/each} + {/if} + +{/if} diff --git a/packages/svelte/src/JsonUIProvider.svelte b/packages/svelte/src/JsonUIProvider.svelte new file mode 100644 index 00000000..52f4e79e --- /dev/null +++ b/packages/svelte/src/JsonUIProvider.svelte @@ -0,0 +1,53 @@ + + +{@render children()} diff --git a/packages/svelte/src/Renderer.svelte b/packages/svelte/src/Renderer.svelte new file mode 100644 index 00000000..3529d14d --- /dev/null +++ b/packages/svelte/src/Renderer.svelte @@ -0,0 +1,25 @@ + + +{#if spec && rootElement} + +{/if} diff --git a/packages/svelte/src/RendererWithProvider.test.svelte b/packages/svelte/src/RendererWithProvider.test.svelte new file mode 100644 index 00000000..d906a743 --- /dev/null +++ b/packages/svelte/src/RendererWithProvider.test.svelte @@ -0,0 +1,54 @@ + + + diff --git a/packages/svelte/src/RepeatChildren.svelte b/packages/svelte/src/RepeatChildren.svelte new file mode 100644 index 00000000..97708a00 --- /dev/null +++ b/packages/svelte/src/RepeatChildren.svelte @@ -0,0 +1,47 @@ + + +{#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}`} + {setRepeatScope({ item: itemValue, index, basePath })} + + {#if element.children} + {#each element.children as childKey (childKey)} + {#if spec.elements[childKey]} + + {:else if !loading} + + {/if} + {/each} + {/if} +{/each} diff --git a/packages/svelte/src/TestButton.svelte b/packages/svelte/src/TestButton.svelte new file mode 100644 index 00000000..00912be8 --- /dev/null +++ b/packages/svelte/src/TestButton.svelte @@ -0,0 +1,14 @@ + + + diff --git a/packages/svelte/src/TestContainer.svelte b/packages/svelte/src/TestContainer.svelte new file mode 100644 index 00000000..8f0e0143 --- /dev/null +++ b/packages/svelte/src/TestContainer.svelte @@ -0,0 +1,19 @@ + + +
+ {#if element.props.title} +

{element.props.title}

+ {/if} + {#if children} + {@render children()} + {/if} +
diff --git a/packages/svelte/src/TestText.svelte b/packages/svelte/src/TestText.svelte new file mode 100644 index 00000000..de20ef76 --- /dev/null +++ b/packages/svelte/src/TestText.svelte @@ -0,0 +1,10 @@ + + +{element.props.text ?? ""} diff --git a/packages/svelte/src/catalog-types.ts b/packages/svelte/src/catalog-types.ts new file mode 100644 index 00000000..8f89341f --- /dev/null +++ b/packages/svelte/src/catalog-types.ts @@ -0,0 +1,78 @@ +import type { 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 +// ============================================================================= + +/** + * Context passed to component render functions + */ +export interface ComponentContext< + C extends Catalog, + K extends keyof InferCatalogComponents, +> { + props: InferComponentProps; + children?: Snippet; + emit: (event: string) => void; + bindings?: Record; + loading?: boolean; +} + +/** + * Component render function type for Svelte + */ +export type ComponentFn< + C extends Catalog, + K extends keyof InferCatalogComponents, +> = (ctx: ComponentContext) => void; + +/** + * Registry of all component render functions 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; +}; diff --git a/packages/svelte/src/contexts/actions.svelte.ts b/packages/svelte/src/contexts/actions.svelte.ts new file mode 100644 index 00000000..4f97c840 --- /dev/null +++ b/packages/svelte/src/contexts/actions.svelte.ts @@ -0,0 +1,295 @@ +import { getContext, setContext } from "svelte"; +import { + resolveAction, + executeAction, + type ActionBinding, + type ActionHandler, + type ActionConfirm, + type ResolvedAction, +} from "@json-render/core"; +import type { StateContext } from "./state.svelte"; + +const ACTION_KEY = Symbol("json-render-actions"); + +/** + * 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. + */ +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 + 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 { + action: ResolvedAction; + handler: ActionHandler; + resolve: () => void; + reject: () => void; +} + +/** + * Action context value + */ +export interface ActionContext { + /** 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; +} + +/** + * Create an action context + */ +export function createActionContext( + stateCtx: StateContext, + initialHandlers: Record = {}, + navigate?: (path: string) => void, +): ActionContext { + // Use $state for reactive parts + let handlers = $state>({ ...initialHandlers }); + let loadingActions = $state>(new Set()); + let pendingConfirmation = $state(null); + + const execute = async (binding: ActionBinding): Promise => { + const resolved = resolveAction(binding, stateCtx.state); + + // Built-in: setState + if (resolved.action === "setState" && resolved.params) { + const statePath = resolved.params.statePath as string; + const value = resolved.params.value; + if (statePath) { + stateCtx.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, stateCtx.get); + const arr = (stateCtx.get(statePath) as unknown[] | undefined) ?? []; + stateCtx.set(statePath, [...arr, resolvedValue]); + // Optionally clear a state path after pushing + const clearStatePath = resolved.params.clearStatePath as + | string + | undefined; + if (clearStatePath) { + stateCtx.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 = (stateCtx.get(statePath) as unknown[] | undefined) ?? []; + stateCtx.set( + statePath, + arr.filter((_, i) => i !== index), + ); + } + return; + } + + // Built-in: push (navigation) + if (resolved.action === "push" && resolved.params) { + const screen = resolved.params.screen as string; + if (screen) { + const currentScreen = stateCtx.get("/currentScreen") as + | string + | undefined; + const navStack = + (stateCtx.get("/navStack") as string[] | undefined) ?? []; + if (currentScreen) { + stateCtx.set("/navStack", [...navStack, currentScreen]); + } else { + stateCtx.set("/navStack", [...navStack, ""]); + } + stateCtx.set("/currentScreen", screen); + } + return; + } + + // Built-in: pop (navigation) + if (resolved.action === "pop") { + const navStack = + (stateCtx.get("/navStack") as string[] | undefined) ?? []; + if (navStack.length > 0) { + const previousScreen = navStack[navStack.length - 1]; + stateCtx.set("/navStack", navStack.slice(0, -1)); + if (previousScreen) { + stateCtx.set("/currentScreen", previousScreen); + } else { + stateCtx.set("/currentScreen", undefined); + } + } + 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) => { + pendingConfirmation = { + action: resolved, + handler, + resolve: () => { + pendingConfirmation = null; + resolve(); + }, + reject: () => { + pendingConfirmation = null; + reject(new Error("Action cancelled")); + }, + }; + }).then(async () => { + loadingActions = new Set(loadingActions).add(resolved.action); + try { + await executeAction({ + action: resolved, + handler, + setState: stateCtx.set, + navigate, + executeAction: async (name) => { + const subBinding: ActionBinding = { action: name }; + await execute(subBinding); + }, + }); + } finally { + const next = new Set(loadingActions); + next.delete(resolved.action); + loadingActions = next; + } + }); + } + + // Execute immediately + loadingActions = new Set(loadingActions).add(resolved.action); + try { + await executeAction({ + action: resolved, + handler, + setState: stateCtx.set, + navigate, + executeAction: async (name) => { + const subBinding: ActionBinding = { action: name }; + await execute(subBinding); + }, + }); + } finally { + const next = new Set(loadingActions); + next.delete(resolved.action); + loadingActions = next; + } + }; + + return { + get handlers() { + return handlers; + }, + get loadingActions() { + return loadingActions; + }, + get pendingConfirmation() { + return pendingConfirmation; + }, + execute, + confirm: () => { + pendingConfirmation?.resolve(); + }, + cancel: () => { + pendingConfirmation?.reject(); + }, + registerHandler: (name: string, handler: ActionHandler) => { + handlers = { ...handlers, [name]: handler }; + }, + }; +} + +/** + * Set the action context in component tree + */ +export function setActionContext(ctx: ActionContext): void { + setContext(ACTION_KEY, ctx); +} + +/** + * Get the action context from component tree + */ +export function getActionContext(): ActionContext { + const ctx = getContext(ACTION_KEY); + if (!ctx) { + throw new Error("getActionContext must be called within a JsonUIProvider"); + } + return ctx; +} diff --git a/packages/svelte/src/contexts/actions.test.ts b/packages/svelte/src/contexts/actions.test.ts new file mode 100644 index 00000000..76db27f1 --- /dev/null +++ b/packages/svelte/src/contexts/actions.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi } from "vitest"; +import { createStateContext } from "./state.svelte"; +import { createActionContext } from "./actions.svelte"; + +describe("createActionContext", () => { + it("executes built-in setState action", async () => { + const stateCtx = createStateContext({ count: 0 }); + const actionCtx = createActionContext(stateCtx); + + await actionCtx.execute({ + action: "setState", + params: { statePath: "/count", value: 5 }, + }); + + expect(stateCtx.state.count).toBe(5); + }); + + it("executes built-in pushState action", async () => { + const stateCtx = createStateContext({ items: ["a", "b"] }); + const actionCtx = createActionContext(stateCtx); + + await actionCtx.execute({ + action: "pushState", + params: { statePath: "/items", value: "c" }, + }); + + expect(stateCtx.state.items).toEqual(["a", "b", "c"]); + }); + + it("pushState creates array if missing", async () => { + const stateCtx = createStateContext({}); + const actionCtx = createActionContext(stateCtx); + + await actionCtx.execute({ + action: "pushState", + params: { statePath: "/newList", value: "first" }, + }); + + expect(stateCtx.get("/newList")).toEqual(["first"]); + }); + + it("executes built-in removeState action", async () => { + const stateCtx = createStateContext({ items: ["a", "b", "c"] }); + const actionCtx = createActionContext(stateCtx); + + await actionCtx.execute({ + action: "removeState", + params: { statePath: "/items", index: 1 }, + }); + + expect(stateCtx.state.items).toEqual(["a", "c"]); + }); + + it("executes push navigation action", async () => { + const stateCtx = createStateContext({ currentScreen: "home" }); + const actionCtx = createActionContext(stateCtx); + + await actionCtx.execute({ + action: "push", + params: { screen: "settings" }, + }); + + expect(stateCtx.get("/currentScreen")).toBe("settings"); + expect(stateCtx.get("/navStack")).toEqual(["home"]); + }); + + it("executes pop navigation action", async () => { + const stateCtx = createStateContext({ + currentScreen: "settings", + navStack: ["home"], + }); + const actionCtx = createActionContext(stateCtx); + + await actionCtx.execute({ action: "pop" }); + + expect(stateCtx.get("/currentScreen")).toBe("home"); + expect(stateCtx.get("/navStack")).toEqual([]); + }); + + it("executes custom handlers", async () => { + const stateCtx = createStateContext({}); + const customHandler = vi.fn().mockResolvedValue(undefined); + const actionCtx = createActionContext(stateCtx, { + myAction: customHandler, + }); + + await actionCtx.execute({ + action: "myAction", + params: { foo: "bar" }, + }); + + expect(customHandler).toHaveBeenCalledWith({ foo: "bar" }); + }); + + it("warns when no handler registered", async () => { + const stateCtx = createStateContext({}); + const actionCtx = createActionContext(stateCtx); + 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", async () => { + const stateCtx = createStateContext({}); + let resolveHandler: () => void; + const slowHandler = vi.fn( + () => + new Promise((resolve) => { + resolveHandler = resolve; + }), + ); + const actionCtx = createActionContext(stateCtx, { + slowAction: slowHandler, + }); + + const executePromise = actionCtx.execute({ action: "slowAction" }); + + expect(actionCtx.loadingActions.has("slowAction")).toBe(true); + + resolveHandler!(); + await executePromise; + + expect(actionCtx.loadingActions.has("slowAction")).toBe(false); + }); + + it("allows registering handlers dynamically", async () => { + const stateCtx = createStateContext({}); + const actionCtx = createActionContext(stateCtx); + const dynamicHandler = vi.fn(); + + actionCtx.registerHandler("dynamicAction", dynamicHandler); + await actionCtx.execute({ action: "dynamicAction", params: { x: 1 } }); + + expect(dynamicHandler).toHaveBeenCalledWith({ x: 1 }); + }); +}); diff --git a/packages/svelte/src/contexts/repeat-scope.ts b/packages/svelte/src/contexts/repeat-scope.ts new file mode 100644 index 00000000..71d0448c --- /dev/null +++ b/packages/svelte/src/contexts/repeat-scope.ts @@ -0,0 +1,29 @@ +import { getContext, setContext } from "svelte"; + +const REPEAT_SCOPE_KEY = Symbol("json-render-repeat-scope"); + +/** + * 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") */ + basePath: string; +} + +/** + * Set the repeat scope in component tree + */ +export function setRepeatScope(scope: RepeatScopeValue): void { + setContext(REPEAT_SCOPE_KEY, scope); +} + +/** + * Get the current repeat scope (or null if not inside a repeated element) + */ +export function getRepeatScope(): RepeatScopeValue | null { + return getContext(REPEAT_SCOPE_KEY) ?? null; +} diff --git a/packages/svelte/src/contexts/state.svelte.ts b/packages/svelte/src/contexts/state.svelte.ts new file mode 100644 index 00000000..b7c8e363 --- /dev/null +++ b/packages/svelte/src/contexts/state.svelte.ts @@ -0,0 +1,64 @@ +import { getContext, setContext } from "svelte"; +import { getByPath, setByPath, type StateModel } from "@json-render/core"; + +const STATE_KEY = Symbol("json-render-state"); + +/** + * State context value + */ +export interface StateContext { + /** The current state model (reactive) */ + readonly state: StateModel; + /** 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; +} + +/** + * Create a state context using Svelte 5 $state rune + */ +export function createStateContext( + initialState: StateModel = {}, + onStateChange?: (path: string, value: unknown) => void, +): StateContext { + // Use $state for reactive state - creates deeply reactive object + let state = $state({ ...initialState }); + + return { + get state() { + return state; + }, + get: (path: string) => getByPath(state, path), + set: (path: string, value: unknown) => { + setByPath(state, path, value); + onStateChange?.(path, value); + }, + update: (updates: Record) => { + for (const [path, value] of Object.entries(updates)) { + setByPath(state, path, value); + onStateChange?.(path, value); + } + }, + }; +} + +/** + * Set the state context in component tree + */ +export function setStateContext(ctx: StateContext): void { + setContext(STATE_KEY, ctx); +} + +/** + * Get the state context from component tree + */ +export function getStateContext(): StateContext { + const ctx = getContext(STATE_KEY); + if (!ctx) { + throw new Error("getStateContext must be called within a JsonUIProvider"); + } + return ctx; +} diff --git a/packages/svelte/src/contexts/state.test.ts b/packages/svelte/src/contexts/state.test.ts new file mode 100644 index 00000000..0c98bcc8 --- /dev/null +++ b/packages/svelte/src/contexts/state.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from "vitest"; +import { createStateContext, type StateContext } from "./state.svelte"; + +describe("createStateContext", () => { + it("provides initial state to consumers", () => { + const ctx = createStateContext({ user: { name: "John" } }); + + expect(ctx.state).toEqual({ user: { name: "John" } }); + }); + + it("provides empty object when no initial state", () => { + const ctx = createStateContext(); + + expect(ctx.state).toEqual({}); + }); +}); + +describe("StateContext.get", () => { + it("retrieves values by path", () => { + const ctx = createStateContext({ user: { name: "John", age: 30 } }); + + expect(ctx.get("/user/name")).toBe("John"); + expect(ctx.get("/user/age")).toBe(30); + }); + + it("returns undefined for missing path", () => { + const ctx = createStateContext({ user: { name: "John" } }); + + expect(ctx.get("/user/email")).toBeUndefined(); + expect(ctx.get("/nonexistent")).toBeUndefined(); + }); +}); + +describe("StateContext.set", () => { + it("updates values at path", () => { + const ctx = createStateContext({ count: 0 }); + + ctx.set("/count", 5); + + expect(ctx.state.count).toBe(5); + }); + + it("creates nested paths", () => { + const ctx = createStateContext({}); + + ctx.set("/user/name", "Jane"); + + expect(ctx.get("/user/name")).toBe("Jane"); + }); + + it("calls onStateChange callback when state changes", () => { + const onStateChange = vi.fn(); + const ctx = createStateContext({ value: 1 }, onStateChange); + + ctx.set("/value", 2); + + expect(onStateChange).toHaveBeenCalledWith("/value", 2); + }); +}); + +describe("StateContext.update", () => { + it("handles multiple values at once", () => { + const ctx = createStateContext({ a: 1, b: 2 }); + + ctx.update({ "/a": 10, "/b": 20 }); + + expect(ctx.state.a).toBe(10); + expect(ctx.state.b).toBe(20); + }); + + it("calls onStateChange for each update", () => { + const onStateChange = vi.fn(); + const ctx = createStateContext({ x: 0, y: 0 }, onStateChange); + + ctx.update({ "/x": 1, "/y": 2 }); + + expect(onStateChange).toHaveBeenCalledWith("/x", 1); + expect(onStateChange).toHaveBeenCalledWith("/y", 2); + expect(onStateChange).toHaveBeenCalledTimes(2); + }); +}); + +describe("StateContext nested paths", () => { + it("handles deeply nested state paths", () => { + const ctx = createStateContext({ + app: { + settings: { + theme: "light", + notifications: { enabled: true }, + }, + }, + }); + + 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"); + }); + + it("handles array indices in paths", () => { + const ctx = createStateContext({ + items: ["a", "b", "c"], + }); + + 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"); + }); +}); diff --git a/packages/svelte/src/contexts/validation.svelte.ts b/packages/svelte/src/contexts/validation.svelte.ts new file mode 100644 index 00000000..e8f8ff64 --- /dev/null +++ b/packages/svelte/src/contexts/validation.svelte.ts @@ -0,0 +1,179 @@ +import { getContext, setContext } from "svelte"; +import { + runValidation, + type ValidationConfig, + type ValidationFunction, + type ValidationResult, +} from "@json-render/core"; +import type { StateContext } from "./state.svelte"; + +const VALIDATION_KEY = Symbol("json-render-validation"); + +/** + * Field validation state + */ +export interface FieldValidationState { + touched: boolean; + validated: boolean; + result: ValidationResult | null; +} + +/** + * Validation context value + */ +export interface ValidationContext { + /** 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; +} + +/** + * Create a validation context + */ +export function createValidationContext( + stateCtx: StateContext, + customFunctions: Record = {}, +): ValidationContext { + let fieldStates = $state>({}); + let fieldConfigs = $state>({}); + + const validate = ( + path: string, + config: ValidationConfig, + ): ValidationResult => { + // Walk the nested state object using JSON Pointer segments + const segments = path.split("/").filter(Boolean); + let value: unknown = stateCtx.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: stateCtx.state, + customFunctions, + }); + + fieldStates = { + ...fieldStates, + [path]: { + touched: fieldStates[path]?.touched ?? true, + validated: true, + result, + }, + }; + + return result; + }; + + const touch = (path: string): void => { + fieldStates = { + ...fieldStates, + [path]: { + ...fieldStates[path], + touched: true, + validated: fieldStates[path]?.validated ?? false, + result: fieldStates[path]?.result ?? null, + }, + }; + }; + + const clear = (path: string): void => { + const { [path]: _, ...rest } = fieldStates; + fieldStates = rest; + }; + + const validateAll = (): boolean => { + let allValid = true; + for (const [path, config] of Object.entries(fieldConfigs)) { + const result = validate(path, config); + if (!result.valid) { + allValid = false; + } + } + return allValid; + }; + + const registerField = (path: string, config: ValidationConfig): void => { + fieldConfigs = { ...fieldConfigs, [path]: config }; + }; + + return { + customFunctions, + get fieldStates() { + return fieldStates; + }, + validate, + touch, + clear, + validateAll, + registerField, + }; +} + +/** + * Set the validation context in component tree + */ +export function setValidationContext(ctx: ValidationContext): void { + setContext(VALIDATION_KEY, ctx); +} + +/** + * Get the validation context from component tree + */ +export function getValidationContext(): ValidationContext { + const ctx = getContext(VALIDATION_KEY); + if (!ctx) { + throw new Error( + "getValidationContext must be called within a JsonUIProvider", + ); + } + return ctx; +} + +/** + * Helper to get field validation state + */ +export function getFieldValidation( + ctx: ValidationContext, + path: string, + config?: ValidationConfig, +): { + state: FieldValidationState; + validate: () => ValidationResult; + touch: () => void; + clear: () => void; + errors: string[]; + isValid: boolean; +} { + const state = ctx.fieldStates[path] ?? { + touched: false, + validated: false, + result: null, + }; + + return { + state, + validate: () => ctx.validate(path, config ?? { checks: [] }), + touch: () => ctx.touch(path), + clear: () => ctx.clear(path), + errors: state.result?.errors ?? [], + isValid: state.result?.valid ?? true, + }; +} diff --git a/packages/svelte/src/contexts/visibility.svelte.ts b/packages/svelte/src/contexts/visibility.svelte.ts new file mode 100644 index 00000000..858aa810 --- /dev/null +++ b/packages/svelte/src/contexts/visibility.svelte.ts @@ -0,0 +1,55 @@ +import { getContext, setContext } from "svelte"; +import { + evaluateVisibility, + type VisibilityCondition, + type VisibilityContext as CoreVisibilityContext, +} from "@json-render/core"; +import type { StateContext } from "./state.svelte"; + +const VISIBILITY_KEY = Symbol("json-render-visibility"); + +/** + * Visibility context value + */ +export interface VisibilityContext { + /** Evaluate a visibility condition */ + isVisible: (condition: VisibilityCondition | undefined) => boolean; + /** The underlying visibility context (for advanced use) */ + ctx: CoreVisibilityContext; +} + +/** + * Create a visibility context that reads from the state context + */ +export function createVisibilityContext( + stateCtx: StateContext, +): VisibilityContext { + return { + get ctx(): CoreVisibilityContext { + return { stateModel: stateCtx.state }; + }, + isVisible: (condition: VisibilityCondition | undefined) => { + return evaluateVisibility(condition, { stateModel: stateCtx.state }); + }, + }; +} + +/** + * Set the visibility context in component tree + */ +export function setVisibilityContext(ctx: VisibilityContext): void { + setContext(VISIBILITY_KEY, ctx); +} + +/** + * Get the visibility context from component tree + */ +export function getVisibilityContext(): VisibilityContext { + const ctx = getContext(VISIBILITY_KEY); + if (!ctx) { + throw new Error( + "getVisibilityContext must be called within a JsonUIProvider", + ); + } + return ctx; +} diff --git a/packages/svelte/src/contexts/visibility.test.ts b/packages/svelte/src/contexts/visibility.test.ts new file mode 100644 index 00000000..8407e3d9 --- /dev/null +++ b/packages/svelte/src/contexts/visibility.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { createStateContext } from "./state.svelte"; +import { createVisibilityContext } from "./visibility.svelte"; + +describe("createVisibilityContext", () => { + it("provides isVisible function", () => { + const stateCtx = createStateContext({}); + const visCtx = createVisibilityContext(stateCtx); + + expect(typeof visCtx.isVisible).toBe("function"); + }); + + it("provides visibility context", () => { + const stateCtx = createStateContext({ value: true }); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.ctx).toBeDefined(); + expect(visCtx.ctx.stateModel).toEqual({ value: true }); + }); +}); + +describe("isVisible", () => { + it("returns true for undefined condition", () => { + const stateCtx = createStateContext({}); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible(undefined)).toBe(true); + }); + + it("returns true for true condition", () => { + const stateCtx = createStateContext({}); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible(true)).toBe(true); + }); + + it("returns false for false condition", () => { + const stateCtx = createStateContext({}); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible(false)).toBe(false); + }); + + it("evaluates $state conditions against data", () => { + const stateCtx = createStateContext({ isLoggedIn: true }); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(true); + + stateCtx.set("/isLoggedIn", false); + + expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(false); + }); + + it("evaluates equality conditions", () => { + const stateCtx = createStateContext({ tab: "home" }); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible({ $state: "/tab", eq: "home" })).toBe(true); + expect(visCtx.isVisible({ $state: "/tab", eq: "settings" })).toBe(false); + }); + + it("evaluates array conditions (implicit AND)", () => { + const stateCtx = createStateContext({ a: true, b: true, c: false }); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible([{ $state: "/a" }, { $state: "/b" }])).toBe(true); + + expect(visCtx.isVisible([{ $state: "/a" }, { $state: "/c" }])).toBe(false); + }); + + it("evaluates $and conditions", () => { + const stateCtx = createStateContext({ x: true, y: false }); + const visCtx = createVisibilityContext(stateCtx); + + expect( + visCtx.isVisible({ $and: [{ $state: "/x" }, { $state: "/y" }] }), + ).toBe(false); + }); + + it("evaluates $or conditions", () => { + const stateCtx = createStateContext({ x: true, y: false }); + const visCtx = createVisibilityContext(stateCtx); + + expect( + visCtx.isVisible({ $or: [{ $state: "/x" }, { $state: "/y" }] }), + ).toBe(true); + }); +}); diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts new file mode 100644 index 00000000..15c7ce10 --- /dev/null +++ b/packages/svelte/src/index.ts @@ -0,0 +1,129 @@ +// ============================================================================= +// Contexts +// ============================================================================= + +export { + createStateContext, + setStateContext, + getStateContext, + type StateContext, +} from "./contexts/state.svelte.js"; + +export { + createVisibilityContext, + setVisibilityContext, + getVisibilityContext, + type VisibilityContext, +} from "./contexts/visibility.svelte.js"; + +export { + createActionContext, + setActionContext, + getActionContext, + type ActionContext, + type PendingConfirmation, +} from "./contexts/actions.svelte.js"; + +export { + createValidationContext, + setValidationContext, + getValidationContext, + getFieldValidation, + type ValidationContext, + type FieldValidationState, +} from "./contexts/validation.svelte.js"; + +export { + setRepeatScope, + getRepeatScope, + type RepeatScopeValue, +} from "./contexts/repeat-scope.js"; + +// ============================================================================= +// Schema +// ============================================================================= + +export { schema, type SvelteSchema, type SvelteSpec } from "./schema.js"; + +// ============================================================================= +// Components +// ============================================================================= + +export { default as Renderer } from "./Renderer.svelte"; +export { default as JsonUIProvider } from "./JsonUIProvider.svelte"; +export { default as ConfirmDialog } from "./ConfirmDialog.svelte"; + +// ============================================================================= +// Types +// ============================================================================= + +export type { + ComponentRenderProps, + ComponentRenderer, + ComponentRegistry, + RendererProps, + JSONUIProviderProps, + CreateRendererProps, +} from "./types.js"; + +// ============================================================================= +// Catalog Types +// ============================================================================= + +export type { + SetState, + StateModel, + ComponentContext, + ComponentFn, + Components, + ActionFn, + Actions, +} from "./catalog-types.js"; + +// ============================================================================= +// Utilities +// ============================================================================= + +export { + flatToTree, + buildSpecFromParts, + getTextFromParts, + type DataPart, +} from "./utils.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, + type DefineRegistryResult, + type ComponentMap, +} from "./registry.js"; + +// ============================================================================= +// Re-exports from core +// ============================================================================= + +export type { + Spec, + UIElement, + ActionBinding, + ActionHandler, +} from "@json-render/core"; diff --git a/packages/svelte/src/registry.ts b/packages/svelte/src/registry.ts new file mode 100644 index 00000000..37c0f7da --- /dev/null +++ b/packages/svelte/src/registry.ts @@ -0,0 +1,141 @@ +import type { Component } from "svelte"; +import type { Catalog } from "@json-render/core"; +import type { ComponentRenderProps, ComponentRenderer } from "./types.js"; +import type { SetState, StateModel } from "./catalog-types.js"; + +/** + * Action handler function for defineRegistry + */ +type DefineRegistryActionFn = ( + params: Record | undefined, + setState: SetState, + state: StateModel, +) => Promise; + +/** + * Result returned by defineRegistry + */ +export interface DefineRegistryResult< + TComponents extends Record> = Record< + string, + ComponentRenderer + >, +> { + /** Component registry for Renderer */ + registry: TComponents; + /** + * 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 `ComponentRenderProps` 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 ComponentRenderProps */ + components?: TComponents; + /** Action handlers */ + actions?: Record; + }, +): DefineRegistryResult { + const registry = (options.components ?? {}) as TComponents; + + // 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 }; +} + +/** + * Component map type - maps component names to Svelte components + * that accept ComponentRenderProps with the appropriate props type. + */ +export type ComponentMap< + TComponents extends Record, +> = { + [K in keyof TComponents]: Component< + ComponentRenderProps< + TComponents[K]["props"] extends { _output: infer O } + ? O + : Record + > + >; +}; diff --git a/packages/svelte/src/renderer.test.ts b/packages/svelte/src/renderer.test.ts new file mode 100644 index 00000000..0e007ab0 --- /dev/null +++ b/packages/svelte/src/renderer.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/svelte"; +import type { Spec } from "@json-render/core"; +import type { ComponentRegistry } from "./types.js"; +import RendererWithProvider from "./RendererWithProvider.test.svelte"; +import TestContainer from "./TestContainer.svelte"; +import TestText from "./TestText.svelte"; +import TestButton from "./TestButton.svelte"; + +describe("Renderer", () => { + afterEach(() => { + cleanup(); + }); + + const registry: ComponentRegistry = { + 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"); + expect(textEl?.getAttribute("data-type")).toBe("Text"); + }); + + 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"); + }); +}); diff --git a/packages/svelte/src/schema.ts b/packages/svelte/src/schema.ts new file mode 100644 index 00000000..aaac353e --- /dev/null +++ b/packages/svelte/src/schema.ts @@ -0,0 +1,87 @@ +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(), + }), + }), + }), + { + 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; diff --git a/packages/svelte/src/streaming.svelte.ts b/packages/svelte/src/streaming.svelte.ts new file mode 100644 index 00000000..f4b88d20 --- /dev/null +++ b/packages/svelte/src/streaming.svelte.ts @@ -0,0 +1,534 @@ +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 })); + historyForApi.push({ role: "user" as const, content: text.trim() }); + + 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, + }; +} diff --git a/packages/svelte/src/types.ts b/packages/svelte/src/types.ts new file mode 100644 index 00000000..ed74e300 --- /dev/null +++ b/packages/svelte/src/types.ts @@ -0,0 +1,92 @@ +import type { Component, Snippet } from "svelte"; +import type { UIElement, Spec, ActionHandler } from "@json-render/core"; + +/** + * 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; + /** + * 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< + string, + ComponentRenderer | undefined +>; + +/** + * 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; +} + +/** + * Props for JSONUIProvider + */ +export interface JSONUIProviderProps { + /** Component registry */ + registry: ComponentRegistry; + /** Initial state model */ + initialState?: Record; + /** Action handlers */ + handlers?: Record; + /** Navigation function */ + navigate?: (path: string) => void; + /** Custom validation functions */ + validationFunctions?: Record< + string, + (value: unknown, args?: Record) => boolean + >; + /** Callback when state changes */ + onStateChange?: (path: string, value: unknown) => void; + /** Children snippet */ + children: Snippet; +} + +/** + * Props for renderers created with createRenderer + */ +export interface CreateRendererProps { + /** The spec to render (AI-generated JSON) */ + spec: Spec | null; + /** State context for dynamic values */ + state?: Record; + /** Action handler */ + onAction?: (actionName: string, params?: Record) => void; + /** Callback when state changes (e.g., from form inputs) */ + onStateChange?: (path: string, value: unknown) => void; + /** Whether the spec is currently loading/streaming */ + loading?: boolean; + /** Fallback component for unknown types */ + fallback?: ComponentRenderer; +} diff --git a/packages/svelte/src/utils.test.ts b/packages/svelte/src/utils.test.ts new file mode 100644 index 00000000..04e5127d --- /dev/null +++ b/packages/svelte/src/utils.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect } from "vitest"; +import { flatToTree, buildSpecFromParts, getTextFromParts } from "./utils.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"); + }); +}); diff --git a/packages/svelte/src/utils.ts b/packages/svelte/src/utils.ts new file mode 100644 index 00000000..18c07c58 --- /dev/null +++ b/packages/svelte/src/utils.ts @@ -0,0 +1,118 @@ +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[]): 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. + */ +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"); +} diff --git a/packages/svelte/svelte.config.js b/packages/svelte/svelte.config.js new file mode 100644 index 00000000..38a77b2d --- /dev/null +++ b/packages/svelte/svelte.config.js @@ -0,0 +1,9 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/package').Config} */ +export default { + preprocess: vitePreprocess(), + compilerOptions: { + runes: true, + }, +}; diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json new file mode 100644 index 00000000..2360df9b --- /dev/null +++ b/packages/svelte/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["svelte"], + "verbatimModuleSyntax": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6afc8ec..5c8ca56e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,18 @@ importers: '@changesets/cli': specifier: 2.29.8 version: 2.29.8(@types/node@22.19.6) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.51.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 '@testing-library/react': specifier: ^16.3.1 version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/svelte': + specifier: ^5.2.0 + version: 5.3.1(svelte@5.51.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@types/react': specifier: ^19.2.3 version: 19.2.3 @@ -38,6 +44,9 @@ importers: react-dom: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) + svelte: + specifier: ^5.0.0 + version: 5.51.2 turbo: specifier: ^2.7.4 version: 2.7.4 @@ -94,10 +103,10 @@ importers: version: 1.36.1 '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 1.6.1(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.51.2) '@vercel/speed-insights': specifier: ^1.3.1 - version: 1.3.1(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 1.3.1(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.51.2) ai: specifier: ^6.0.33 version: 6.0.33(zod@4.3.5) @@ -728,6 +737,31 @@ importers: specifier: ^4.0.0 version: 4.3.5 + packages/svelte: + dependencies: + '@json-render/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@repo/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@sveltejs/package': + specifier: ^2.3.0 + version: 2.5.7(svelte@5.51.2)(typescript@5.9.2) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.51.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + svelte: + specifier: ^5.0.0 + version: 5.51.2 + svelte-check: + specifier: ^4.0.0 + version: 4.4.0(picomatch@4.0.3)(svelte@5.51.2)(typescript@5.9.2) + typescript: + specifier: ^5.4.5 + version: 5.9.2 + packages/typescript-config: {} packages/ui: @@ -4001,6 +4035,33 @@ packages: '@stripe/ui-extension-tools@0.0.1': resolution: {integrity: sha512-0pOgQ3AuEUeypAgAhcJbyC9QxMaMW1OqzzxkCO4a+5ALDyIFEA6M4jDQ3H9KayNwqQ23qq+PQ0rlYY7dRACNgA==} + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/package@2.5.7': + resolution: {integrity: sha512-qqD9xa9H7TDiGFrF6rz7AirOR8k15qDK/9i4MIE8te4vWsv5GEogPks61rrZcLy+yWph+aI6pIj2MdoK3YI8AQ==} + engines: {node: ^16.14 || >=18} + hasBin: true + peerDependencies: + svelte: ^3.44.0 || ^4.0.0 || ^5.0.0-next.1 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -4111,6 +4172,25 @@ packages: '@types/react-dom': optional: true + '@testing-library/svelte-core@1.0.0': + resolution: {integrity: sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==} + engines: {node: '>=16'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + + '@testing-library/svelte@5.3.1': + resolution: {integrity: sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==} + engines: {node: '>= 10'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + vite: '*' + vitest: '*' + peerDependenciesMeta: + vite: + optional: true + vitest: + optional: true + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -4276,6 +4356,9 @@ packages: '@types/three@0.182.0': resolution: {integrity: sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -4748,6 +4831,10 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -4805,6 +4892,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + babel-jest@27.5.1: resolution: {integrity: sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -5068,6 +5159,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -5383,6 +5478,9 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + dedent-js@1.0.1: + resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} + dedent@0.7.0: resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} @@ -5446,6 +5544,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devalue@5.6.2: + resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -5841,6 +5942,9 @@ packages: jiti: optional: true + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5858,6 +5962,9 @@ packages: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} + esrap@2.2.3: + resolution: {integrity: sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -6676,6 +6783,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -7068,6 +7178,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + lan-network@0.1.7: resolution: {integrity: sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==} hasBin: true @@ -7184,6 +7298,9 @@ packages: resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} engines: {node: '>=8.9.0'} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -8359,6 +8476,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + recharts-scale@0.4.5: resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} @@ -8545,6 +8666,10 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -8592,6 +8717,9 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} @@ -8990,6 +9118,24 @@ packages: peerDependencies: react: '>=17.0' + svelte-check@4.4.0: + resolution: {integrity: sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte2tsx@0.7.48: + resolution: {integrity: sha512-B15C8dtOY6C9MbnQJDCkzbK3yByInzKtXrr23QCoF8APHMh6JaDhjCMcRl6ay4qaeKYqkX4X3tNaJrsZL45Zlg==} + peerDependencies: + svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 + typescript: ^4.9.4 || ^5.0.0 + + svelte@5.51.2: + resolution: {integrity: sha512-AqApqNOxVS97V4Ko9UHTHeSuDJrwauJhZpLDs1gYD8Jk48ntCSWD7NxKje+fnGn5Ja1O3u2FzQZHPdifQjXe3w==} + engines: {node: '>=18'} + swr@2.4.0: resolution: {integrity: sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==} peerDependencies: @@ -9548,6 +9694,14 @@ packages: yaml: optional: true + vitefu@1.1.1: + resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + vitest@4.0.17: resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -9843,6 +9997,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -14717,6 +14874,43 @@ snapshots: - ts-node - utf-8-validate + '@sveltejs/acorn-typescript@1.0.9(acorn@8.15.0)': + dependencies: + acorn: 8.15.0 + + '@sveltejs/package@2.5.7(svelte@5.51.2)(typescript@5.9.2)': + dependencies: + chokidar: 5.0.0 + kleur: 4.1.5 + sade: 1.8.1 + semver: 7.7.3 + svelte: 5.51.2 + svelte2tsx: 0.7.48(svelte@5.51.2)(typescript@5.9.2) + transitivePeerDependencies: + - typescript + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.51.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + debug: 4.4.3 + svelte: 5.51.2 + vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.51.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.51.2 + vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + transitivePeerDependencies: + - supports-color + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -14811,6 +15005,19 @@ snapshots: '@types/react': 19.2.3 '@types/react-dom': 19.2.3(@types/react@19.2.3) + '@testing-library/svelte-core@1.0.0(svelte@5.51.2)': + dependencies: + svelte: 5.51.2 + + '@testing-library/svelte@5.3.1(svelte@5.51.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/svelte-core': 1.0.0(svelte@5.51.2) + svelte: 5.51.2 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -14999,6 +15206,8 @@ snapshots: fflate: 0.8.2 meshoptimizer: 0.22.0 + '@types/trusted-types@2.0.7': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -15233,17 +15442,19 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.4 - '@vercel/analytics@1.6.1(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@vercel/analytics@1.6.1(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.51.2)': optionalDependencies: next: 16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 + svelte: 5.51.2 '@vercel/oidc@3.1.0': {} - '@vercel/speed-insights@1.3.1(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@vercel/speed-insights@1.3.1(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.51.2)': optionalDependencies: next: 16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 + svelte: 5.51.2 '@vitest/expect@4.0.17': dependencies: @@ -15516,6 +15727,8 @@ snapshots: dependencies: dequal: 2.0.3 + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -15591,6 +15804,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axobject-query@4.1.0: {} + babel-jest@27.5.1(@babel/core@7.29.0): dependencies: '@babel/core': 7.29.0 @@ -15948,6 +16163,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + chownr@1.1.4: optional: true @@ -16256,6 +16475,8 @@ snapshots: mimic-response: 3.1.0 optional: true + dedent-js@1.0.1: {} + dedent@0.7.0: {} deep-extend@0.6.0: {} @@ -16302,6 +16523,8 @@ snapshots: detect-node-es@1.1.0: {} + devalue@5.6.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -16849,6 +17072,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm-env@1.2.2: {} + espree@10.4.0: dependencies: acorn: 8.15.0 @@ -16867,6 +17092,10 @@ snapshots: dependencies: estraverse: 5.3.0 + esrap@2.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -17938,6 +18167,10 @@ snapshots: is-promise@2.2.2: {} + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -18667,6 +18900,8 @@ snapshots: kleur@3.0.3: {} + kleur@4.1.5: {} + lan-network@0.1.7: {} leven@3.1.0: {} @@ -18769,6 +19004,8 @@ snapshots: emojis-list: 3.0.0 json5: 2.2.3 + locate-character@3.0.0: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -20679,6 +20916,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: {} + recharts-scale@0.4.5: dependencies: decimal.js-light: 2.5.1 @@ -20970,6 +21209,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + sade@1.8.1: + dependencies: + mri: 1.2.0 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -21024,6 +21267,8 @@ snapshots: ajv-formats: 2.1.1(ajv@8.17.1) ajv-keywords: 5.1.0(ajv@8.17.1) + scule@1.3.0: {} + section-matter@1.0.0: dependencies: extend-shallow: 2.0.1 @@ -21511,6 +21756,44 @@ snapshots: dependencies: react: 19.2.4 + svelte-check@4.4.0(picomatch@4.0.3)(svelte@5.51.2)(typescript@5.9.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.3) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.51.2 + typescript: 5.9.2 + transitivePeerDependencies: + - picomatch + + svelte2tsx@0.7.48(svelte@5.51.2)(typescript@5.9.2): + dependencies: + dedent-js: 1.0.1 + scule: 1.3.0 + svelte: 5.51.2 + typescript: 5.9.2 + + svelte@5.51.2: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) + '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 + acorn: 8.15.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.6.2 + esm-env: 1.2.2 + esrap: 2.2.3 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + swr@2.4.0(react@19.2.3): dependencies: dequal: 2.0.3 @@ -22129,6 +22412,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vitefu@1.1.1(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + optionalDependencies: + vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.17 @@ -22427,6 +22714,8 @@ snapshots: yocto-queue@0.1.0: {} + zimmerframe@1.1.4: {} + zod@3.22.3: {} zod@3.25.76: {} diff --git a/vitest.config.ts b/vitest.config.mts similarity index 66% rename from vitest.config.ts rename to vitest.config.mts index dce63b75..66a381e1 100644 --- a/vitest.config.ts +++ b/vitest.config.mts @@ -1,8 +1,15 @@ import { defineConfig } from "vitest/config"; import path from "path"; +import { fileURLToPath } from "url"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + plugins: [svelte({ hot: false })], resolve: { + // Ensure Svelte resolves to browser bundle, not server + conditions: ["browser"], // Deduplicate React so tests don't get two copies // (pnpm strict resolution can cause packages/react to resolve a different copy) alias: { @@ -17,7 +24,7 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "json", "html"], - include: ["packages/*/src/**/*.{ts,tsx}"], + include: ["packages/*/src/**/*.{ts,tsx,svelte}"], exclude: ["**/*.test.{ts,tsx}", "**/index.ts"], }, }, From 340c928e443342df69ccd73f11b1685641530ac4 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 25 Feb 2026 22:01:46 +0100 Subject: [PATCH 02/15] helpers --- packages/svelte/src/JsonUIProvider.svelte | 30 +- .../src/RendererWithProvider.test.svelte | 8 - .../svelte/src/contexts/actions.svelte.ts | 26 +- packages/svelte/src/contexts/state.svelte.ts | 48 ++- .../svelte/src/contexts/validation.svelte.ts | 8 +- .../svelte/src/contexts/visibility.svelte.ts | 26 +- packages/svelte/src/index.ts | 10 +- .../svelte/src/{utils.ts => utils.svelte.ts} | 10 +- packages/svelte/src/utils.test.ts | 6 +- skills/json-render-svelte/SKILL.md | 354 ++++++++++++++++++ 10 files changed, 463 insertions(+), 63 deletions(-) rename packages/svelte/src/{utils.ts => utils.svelte.ts} (93%) create mode 100644 skills/json-render-svelte/SKILL.md diff --git a/packages/svelte/src/JsonUIProvider.svelte b/packages/svelte/src/JsonUIProvider.svelte index 52f4e79e..08483ff8 100644 --- a/packages/svelte/src/JsonUIProvider.svelte +++ b/packages/svelte/src/JsonUIProvider.svelte @@ -1,22 +1,10 @@ {@render children()} diff --git a/packages/svelte/src/RendererWithProvider.test.svelte b/packages/svelte/src/RendererWithProvider.test.svelte index d906a743..5b93cf04 100644 --- a/packages/svelte/src/RendererWithProvider.test.svelte +++ b/packages/svelte/src/RendererWithProvider.test.svelte @@ -3,19 +3,15 @@ import type { ComponentRegistry, ComponentRenderer } from "./types.js"; import { createStateContext, - setStateContext, } from "./contexts/state.svelte.js"; import { createVisibilityContext, - setVisibilityContext, } from "./contexts/visibility.svelte.js"; import { createActionContext, - setActionContext, } from "./contexts/actions.svelte.js"; import { createValidationContext, - setValidationContext, } from "./contexts/validation.svelte.js"; import Renderer from "./Renderer.svelte"; @@ -39,16 +35,12 @@ // Create and provide contexts const stateCtx = createStateContext(initialState); - setStateContext(stateCtx); const visibilityCtx = createVisibilityContext(stateCtx); - setVisibilityContext(visibilityCtx); const actionCtx = createActionContext(stateCtx, handlers); - setActionContext(actionCtx); const validationCtx = createValidationContext(stateCtx); - setValidationContext(validationCtx); diff --git a/packages/svelte/src/contexts/actions.svelte.ts b/packages/svelte/src/contexts/actions.svelte.ts index 4f97c840..e93d7a4f 100644 --- a/packages/svelte/src/contexts/actions.svelte.ts +++ b/packages/svelte/src/contexts/actions.svelte.ts @@ -76,6 +76,10 @@ export interface PendingConfirmation { reject: () => void; } +export interface CurrentValue { + readonly current: T; +} + /** * Action context value */ @@ -253,7 +257,7 @@ export function createActionContext( } }; - return { + const ctx: ActionContext = { get handlers() { return handlers; }, @@ -274,13 +278,9 @@ export function createActionContext( handlers = { ...handlers, [name]: handler }; }, }; -} -/** - * Set the action context in component tree - */ -export function setActionContext(ctx: ActionContext): void { setContext(ACTION_KEY, ctx); + return ctx; } /** @@ -293,3 +293,17 @@ export function getActionContext(): ActionContext { } return ctx; } + +/** + * Convenience helper to get a registered action handler by name + */ +export function getAction( + name: string, +): CurrentValue { + const context = getActionContext(); + return { + get current() { + return context.handlers[name]; + }, + }; +} diff --git a/packages/svelte/src/contexts/state.svelte.ts b/packages/svelte/src/contexts/state.svelte.ts index b7c8e363..a11c3836 100644 --- a/packages/svelte/src/contexts/state.svelte.ts +++ b/packages/svelte/src/contexts/state.svelte.ts @@ -17,6 +17,10 @@ export interface StateContext { update: (updates: Record) => void; } +export interface CurrentValue { + current: T; +} + /** * Create a state context using Svelte 5 $state rune */ @@ -27,7 +31,7 @@ export function createStateContext( // Use $state for reactive state - creates deeply reactive object let state = $state({ ...initialState }); - return { + const ctx: StateContext = { get state() { return state; }, @@ -43,13 +47,9 @@ export function createStateContext( } }, }; -} -/** - * Set the state context in component tree - */ -export function setStateContext(ctx: StateContext): void { setContext(STATE_KEY, ctx); + return ctx; } /** @@ -62,3 +62,39 @@ export function getStateContext(): StateContext { } return ctx; } + +/** + * Convenience helper to read a value from the state context + */ +export function getStateValue(path: string): CurrentValue { + const context = getStateContext(); + return { + get current() { + return context.get(path); + }, + set current(value: unknown) { + context.set(path, value); + }, + }; +} + +/** + * Two-way helper for `$bindState` / `$bindItem` bindings. + * Mirrors `useBoundProp` from React packages. + */ +export function getBoundProp( + propValue: () => T | undefined, + bindingPath: string | undefined, +): CurrentValue { + const context = getStateContext(); + return { + get current() { + return propValue(); + }, + set current(value: T | undefined) { + if (bindingPath) { + context.set(bindingPath, value); + } + }, + }; +} diff --git a/packages/svelte/src/contexts/validation.svelte.ts b/packages/svelte/src/contexts/validation.svelte.ts index e8f8ff64..6961c156 100644 --- a/packages/svelte/src/contexts/validation.svelte.ts +++ b/packages/svelte/src/contexts/validation.svelte.ts @@ -114,7 +114,7 @@ export function createValidationContext( fieldConfigs = { ...fieldConfigs, [path]: config }; }; - return { + const ctx: ValidationContext = { customFunctions, get fieldStates() { return fieldStates; @@ -125,13 +125,9 @@ export function createValidationContext( validateAll, registerField, }; -} -/** - * Set the validation context in component tree - */ -export function setValidationContext(ctx: ValidationContext): void { setContext(VALIDATION_KEY, ctx); + return ctx; } /** diff --git a/packages/svelte/src/contexts/visibility.svelte.ts b/packages/svelte/src/contexts/visibility.svelte.ts index 858aa810..1853dca3 100644 --- a/packages/svelte/src/contexts/visibility.svelte.ts +++ b/packages/svelte/src/contexts/visibility.svelte.ts @@ -18,13 +18,17 @@ export interface VisibilityContext { ctx: CoreVisibilityContext; } +export interface CurrentValue { + readonly current: T; +} + /** * Create a visibility context that reads from the state context */ export function createVisibilityContext( stateCtx: StateContext, ): VisibilityContext { - return { + const ctx: VisibilityContext = { get ctx(): CoreVisibilityContext { return { stateModel: stateCtx.state }; }, @@ -32,13 +36,9 @@ export function createVisibilityContext( return evaluateVisibility(condition, { stateModel: stateCtx.state }); }, }; -} -/** - * Set the visibility context in component tree - */ -export function setVisibilityContext(ctx: VisibilityContext): void { setContext(VISIBILITY_KEY, ctx); + return ctx; } /** @@ -53,3 +53,17 @@ export function getVisibilityContext(): VisibilityContext { } return ctx; } + +/** + * Convenience helper to evaluate visibility from context + */ +export function isVisible( + condition: VisibilityCondition | undefined, +): CurrentValue { + const context = getVisibilityContext(); + return { + get current() { + return context.isVisible(condition); + }, + }; +} diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 15c7ce10..fb6c8deb 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -4,29 +4,29 @@ export { createStateContext, - setStateContext, getStateContext, + getStateValue, + getBoundProp, type StateContext, } from "./contexts/state.svelte.js"; export { createVisibilityContext, - setVisibilityContext, getVisibilityContext, + isVisible, type VisibilityContext, } from "./contexts/visibility.svelte.js"; export { createActionContext, - setActionContext, getActionContext, + getAction, type ActionContext, type PendingConfirmation, } from "./contexts/actions.svelte.js"; export { createValidationContext, - setValidationContext, getValidationContext, getFieldValidation, type ValidationContext, @@ -89,7 +89,7 @@ export { buildSpecFromParts, getTextFromParts, type DataPart, -} from "./utils.js"; +} from "./utils.svelte.js"; // ============================================================================= // Streaming diff --git a/packages/svelte/src/utils.ts b/packages/svelte/src/utils.svelte.ts similarity index 93% rename from packages/svelte/src/utils.ts rename to packages/svelte/src/utils.svelte.ts index 18c07c58..4c3bf801 100644 --- a/packages/svelte/src/utils.ts +++ b/packages/svelte/src/utils.svelte.ts @@ -78,7 +78,10 @@ function isSpecDataPart(data: unknown): data is SpecDataPart { * 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[]): Spec | null { +export function buildSpecFromParts( + parts: DataPart[], + snapshot = true, +): Spec | null { const spec: Spec = { root: "", elements: {} }; let hasSpec = false; @@ -88,7 +91,10 @@ export function buildSpecFromParts(parts: DataPart[]): Spec | null { const payload = part.data; if (payload.type === "patch") { hasSpec = true; - applySpecPatch(spec, payload.patch); + applySpecPatch( + spec, + snapshot ? $state.snapshot(payload.patch) : payload.patch, + ); } else if (payload.type === "flat") { hasSpec = true; Object.assign(spec, payload.spec); diff --git a/packages/svelte/src/utils.test.ts b/packages/svelte/src/utils.test.ts index 04e5127d..afe595f8 100644 --- a/packages/svelte/src/utils.test.ts +++ b/packages/svelte/src/utils.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { flatToTree, buildSpecFromParts, getTextFromParts } from "./utils.js"; +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"; diff --git a/skills/json-render-svelte/SKILL.md b/skills/json-render-svelte/SKILL.md new file mode 100644 index 00000000..1895ac7d --- /dev/null +++ b/skills/json-render-svelte/SKILL.md @@ -0,0 +1,354 @@ +--- +name: json-render-svelte +description: Svelte 5 renderer for json-render that turns JSON specs into Svelte components. Use when working with @json-render/svelte, building Svelte UIs from JSON, creating component catalogs, or rendering AI-generated specs. +--- + +# @json-render/svelte + +Svelte 5 renderer that converts JSON specs into Svelte component trees. Uses runes (`$state`, `$derived`, `$props`) and modern template syntax (`{#snippet}`, `{@render}`). + +## Quick Start + +```svelte + + + + + +``` + +## Creating a Catalog + +```typescript +import { defineCatalog } from "@json-render/core"; +import { schema, defineRegistry } from "@json-render/svelte"; +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", + }, + }, +}); +``` + +## Defining Components + +Components must be `.svelte` files that accept `ComponentRenderProps`: + +```typescript +interface ComponentRenderProps { + element: UIElement; // The element with resolved props + bindings?: Record; // Map of prop names to state paths (for $bindState) + loading?: boolean; // True while spec is streaming + emit: (event: string) => void; // Fire a named event + children?: Snippet; // Child elements (use {@render children()}) +} +``` + +```svelte + + + + +``` + +```svelte + + + +

+

{element.props.title}

+ {#if children} + {@render children()} + {/if} +
+``` + +## Creating a Registry + +```typescript +import { defineRegistry } from "@json-render/svelte"; +import { catalog } from "./catalog"; +import Card from "./components/Card.svelte"; +import Button from "./components/Button.svelte"; + +const { registry, handlers, executeAction } = defineRegistry(catalog, { + components: { + Card, + Button, + }, + actions: { + submit: async (params, setState, state) => { + // handle action + }, + }, +}); +``` + +## Spec Structure (Element Tree) + +The Svelte schema uses the element tree format: + +```json +{ + "root": "card1", + "elements": { + "card1": { + "type": "Card", + "props": { "title": "Hello" }, + "children": ["btn1"] + }, + "btn1": { + "type": "Button", + "props": { "label": "Click me" } + } + } +} +``` + +## Visibility Conditions + +Use `visible` on elements to show/hide based on state: + +- `{ "$state": "/path" }` - truthy check +- `{ "$state": "/path", "eq": value }` - equality check +- `{ "$state": "/path", "not": true }` - falsy check +- `{ "$and": [cond1, cond2] }` - AND conditions +- `{ "$or": [cond1, cond2] }` - OR conditions + +## Providers (via JsonUIProvider) + +`JsonUIProvider` composes all contexts. Individual contexts: + +| Context | Purpose | +| ------------------- | -------------------------------------------------- | +| `StateContext` | Share state across components (JSON Pointer paths) | +| `ActionContext` | Handle actions dispatched via the event system | +| `VisibilityContext` | Enable conditional rendering based on state | +| `ValidationContext` | Form field validation | + +## Dynamic Prop Expressions + +Any prop value can be a data-driven expression resolved 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 +- **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field +- **`{ "$cond": , "$then": , "$else": }`** - conditional value + +```json +{ + "type": "Input", + "props": { + "value": { "$bindState": "/form/email" }, + "placeholder": "Email" + } +} +``` + +## Event System + +Components use `emit` to fire named events. The element's `on` field maps events to action bindings: + +```svelte + + + + +``` + +```json +{ + "type": "Button", + "props": { "label": "Submit" }, + "on": { "press": { "action": "submit" } } +} +``` + +## Built-in Actions + +The `setState` action is handled automatically and updates the state model: + +```json +{ + "action": "setState", + "actionParams": { "statePath": "/activeTab", "value": "home" } +} +``` + +Other built-in actions: `pushState`, `removeState`, `push`, `pop`. + +## Two-Way Binding in Components + +For form components that need two-way binding, prefer `getBoundProp`: + +```svelte + + + + +``` + +## Accessing Contexts + +Prefer helpers for common operations. They return an object with a reactive `.current` property. +For `getStateValue` and `getBoundProp`, `.current` is writable: + +```svelte + +``` + +Use direct context access for advanced scenarios (bulk operations, full context APIs): + +```svelte + +``` + +## Streaming UI + +```svelte + + + + +{#if stream.spec} + +{/if} +``` + +## Key Exports + +| Export | Purpose | +| ---------------------- | ---------------------------------------------------- | +| `defineRegistry` | Create a type-safe component registry from a catalog | +| `Renderer` | Render a spec using a registry | +| `JsonUIProvider` | Provide all contexts to the component tree | +| `schema` | Element tree schema | +| `getStateValue` | Read/write state via `.current` (preferred) | +| `getBoundProp` | Read/write bound prop via `.current` (preferred) | +| `isVisible` | Evaluate visibility via `.current` (preferred) | +| `getAction` | Read action handler via `.current` (preferred) | +| `getStateContext` | Access full state context (advanced) | +| `getActionContext` | Access full actions context (advanced) | +| `getVisibilityContext` | Access full visibility context (advanced) | +| `getValidationContext` | Access validation context | +| `createUIStream` | Stream specs from an API endpoint | +| `createChatUI` | Chat interface with integrated UI generation | From c1a6827d7affcaa52eea20428ccd66c9bf9fcd96 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 25 Feb 2026 23:29:04 +0100 Subject: [PATCH 03/15] reactive API --- packages/svelte/package.json | 2 +- packages/svelte/src/JsonUIProvider.svelte | 28 +- .../src/RendererWithProvider.test.svelte | 28 +- .../svelte/src/contexts/actions.svelte.ts | 64 ++- packages/svelte/src/contexts/actions.test.ts | 370 +++++++++++------- packages/svelte/src/contexts/state.svelte.ts | 114 +++++- packages/svelte/src/contexts/state.test.ts | 263 ++++++++----- .../svelte/src/contexts/validation.svelte.ts | 22 +- .../svelte/src/contexts/visibility.test.ts | 213 ++++++---- packages/svelte/src/schema.ts | 22 ++ packages/svelte/src/types.ts | 27 +- packages/svelte/tsconfig.json | 2 +- vitest.config.mts | 1 - 13 files changed, 798 insertions(+), 358 deletions(-) diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 32df4ed6..89315201 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -57,7 +57,7 @@ "@json-render/core": "workspace:*" }, "devDependencies": { - "@repo/typescript-config": "workspace:*", + "@internal/typescript-config": "workspace:*", "@sveltejs/package": "^2.3.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", diff --git a/packages/svelte/src/JsonUIProvider.svelte b/packages/svelte/src/JsonUIProvider.svelte index 08483ff8..57de976a 100644 --- a/packages/svelte/src/JsonUIProvider.svelte +++ b/packages/svelte/src/JsonUIProvider.svelte @@ -1,21 +1,27 @@ {@render children()} diff --git a/packages/svelte/src/RendererWithProvider.test.svelte b/packages/svelte/src/RendererWithProvider.test.svelte index 5b93cf04..8355cd51 100644 --- a/packages/svelte/src/RendererWithProvider.test.svelte +++ b/packages/svelte/src/RendererWithProvider.test.svelte @@ -1,18 +1,10 @@ diff --git a/packages/svelte/src/contexts/actions.svelte.ts b/packages/svelte/src/contexts/actions.svelte.ts index e93d7a4f..24b1bbae 100644 --- a/packages/svelte/src/contexts/actions.svelte.ts +++ b/packages/svelte/src/contexts/actions.svelte.ts @@ -8,6 +8,7 @@ import { type ResolvedAction, } from "@json-render/core"; import type { StateContext } from "./state.svelte"; +import type { FieldValidationState } from "./validation.svelte.js"; const ACTION_KEY = Symbol("json-render-actions"); @@ -100,21 +101,41 @@ export interface ActionContext { registerHandler: (name: string, handler: ActionHandler) => void; } +interface ValidationContextLike { + validateAll: () => boolean; + fieldStates: Record; +} + +type CreateActionContextOptions = { + stateCtx: StateContext; + handlers?: Record; + navigate?: (path: string) => void; + validation?: ValidationContextLike; +}; + +type CreateActionContextInput = + | CreateActionContextOptions + | (() => CreateActionContextOptions); + /** * Create an action context */ export function createActionContext( - stateCtx: StateContext, - initialHandlers: Record = {}, - navigate?: (path: string) => void, + optionsOrGetter: CreateActionContextInput, ): ActionContext { + const getOptions = + typeof optionsOrGetter === "function" + ? optionsOrGetter + : () => optionsOrGetter; + // Use $state for reactive parts - let handlers = $state>({ ...initialHandlers }); + let registeredHandlers = $state>({}); let loadingActions = $state>(new Set()); let pendingConfirmation = $state(null); const execute = async (binding: ActionBinding): Promise => { - const resolved = resolveAction(binding, stateCtx.state); + const { stateCtx, navigate, validation } = getOptions(); + const resolved = resolveAction(binding, stateCtx.getSnapshot()); // Built-in: setState if (resolved.action === "setState" && resolved.params) { @@ -159,6 +180,28 @@ export function createActionContext( return; } + // Built-in: validateForm — triggers validateAll and writes result to state + if (resolved.action === "validateForm") { + if (!validation?.validateAll) { + console.warn( + "validateForm action was dispatched but no ValidationProvider is connected. " + + "Ensure ValidationProvider is rendered inside the provider tree.", + ); + return; + } + const valid = validation.validateAll(); + const errors: Record = {}; + for (const [path, fieldState] of Object.entries(validation.fieldStates)) { + if (fieldState.result && !fieldState.result.valid) { + errors[path] = fieldState.result.errors; + } + } + const statePath = + (resolved.params?.statePath as string) || "/formValidation"; + stateCtx.set(statePath, { valid, errors }); + return; + } + // Built-in: push (navigation) if (resolved.action === "push" && resolved.params) { const screen = resolved.params.screen as string; @@ -194,7 +237,9 @@ export function createActionContext( return; } - const handler = handlers[resolved.action]; + const handler = + registeredHandlers[resolved.action] ?? + (getOptions().handlers ?? {})[resolved.action]; if (!handler) { console.warn(`No handler registered for action: ${resolved.action}`); @@ -259,7 +304,10 @@ export function createActionContext( const ctx: ActionContext = { get handlers() { - return handlers; + return { + ...(getOptions().handlers ?? {}), + ...registeredHandlers, + }; }, get loadingActions() { return loadingActions; @@ -275,7 +323,7 @@ export function createActionContext( pendingConfirmation?.reject(); }, registerHandler: (name: string, handler: ActionHandler) => { - handlers = { ...handlers, [name]: handler }; + registeredHandlers = { ...registeredHandlers, [name]: handler }; }, }; diff --git a/packages/svelte/src/contexts/actions.test.ts b/packages/svelte/src/contexts/actions.test.ts index 76db27f1..3ba91cea 100644 --- a/packages/svelte/src/contexts/actions.test.ts +++ b/packages/svelte/src/contexts/actions.test.ts @@ -1,141 +1,243 @@ import { describe, it, expect, vi } from "vitest"; +import { mount, unmount } from "svelte"; import { createStateContext } from "./state.svelte"; import { createActionContext } from "./actions.svelte"; -describe("createActionContext", () => { - it("executes built-in setState action", async () => { - const stateCtx = createStateContext({ count: 0 }); - const actionCtx = createActionContext(stateCtx); - - await actionCtx.execute({ - action: "setState", - params: { statePath: "/count", value: 5 }, - }); - - expect(stateCtx.state.count).toBe(5); - }); - - it("executes built-in pushState action", async () => { - const stateCtx = createStateContext({ items: ["a", "b"] }); - const actionCtx = createActionContext(stateCtx); - - await actionCtx.execute({ - action: "pushState", - params: { statePath: "/items", value: "c" }, - }); - - expect(stateCtx.state.items).toEqual(["a", "b", "c"]); - }); - - it("pushState creates array if missing", async () => { - const stateCtx = createStateContext({}); - const actionCtx = createActionContext(stateCtx); - - await actionCtx.execute({ - action: "pushState", - params: { statePath: "/newList", value: "first" }, - }); - - expect(stateCtx.get("/newList")).toEqual(["first"]); - }); - - it("executes built-in removeState action", async () => { - const stateCtx = createStateContext({ items: ["a", "b", "c"] }); - const actionCtx = createActionContext(stateCtx); - - await actionCtx.execute({ - action: "removeState", - params: { statePath: "/items", index: 1 }, - }); - - expect(stateCtx.state.items).toEqual(["a", "c"]); - }); - - it("executes push navigation action", async () => { - const stateCtx = createStateContext({ currentScreen: "home" }); - const actionCtx = createActionContext(stateCtx); - - await actionCtx.execute({ - action: "push", - params: { screen: "settings" }, - }); - - expect(stateCtx.get("/currentScreen")).toBe("settings"); - expect(stateCtx.get("/navStack")).toEqual(["home"]); - }); - - it("executes pop navigation action", async () => { - const stateCtx = createStateContext({ - currentScreen: "settings", - navStack: ["home"], - }); - const actionCtx = createActionContext(stateCtx); - - await actionCtx.execute({ action: "pop" }); - - expect(stateCtx.get("/currentScreen")).toBe("home"); - expect(stateCtx.get("/navStack")).toEqual([]); - }); - - it("executes custom handlers", async () => { - const stateCtx = createStateContext({}); - const customHandler = vi.fn().mockResolvedValue(undefined); - const actionCtx = createActionContext(stateCtx, { - myAction: customHandler, - }); - - await actionCtx.execute({ - action: "myAction", - params: { foo: "bar" }, - }); - - expect(customHandler).toHaveBeenCalledWith({ foo: "bar" }); - }); - - it("warns when no handler registered", async () => { - const stateCtx = createStateContext({}); - const actionCtx = createActionContext(stateCtx); - 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", async () => { - const stateCtx = createStateContext({}); - let resolveHandler: () => void; - const slowHandler = vi.fn( - () => - new Promise((resolve) => { - resolveHandler = resolve; - }), +function component(runTest: () => Promise) { + return async () => { + let promise: Promise; + const c = mount( + (() => { + promise = runTest(); + }) as any, + { target: document.body }, ); - const actionCtx = createActionContext(stateCtx, { - slowAction: slowHandler, - }); - - const executePromise = actionCtx.execute({ action: "slowAction" }); - - expect(actionCtx.loadingActions.has("slowAction")).toBe(true); - - resolveHandler!(); - await executePromise; + await promise!; // eslint-disable-line @typescript-eslint/no-non-null-assertion + unmount(c); + }; +} - expect(actionCtx.loadingActions.has("slowAction")).toBe(false); - }); - - it("allows registering handlers dynamically", async () => { - const stateCtx = createStateContext({}); - const actionCtx = createActionContext(stateCtx); - const dynamicHandler = vi.fn(); - - actionCtx.registerHandler("dynamicAction", dynamicHandler); - await actionCtx.execute({ action: "dynamicAction", params: { x: 1 } }); - - expect(dynamicHandler).toHaveBeenCalledWith({ x: 1 }); - }); +describe("createActionContext", () => { + it( + "executes built-in setState action", + component(async () => { + const stateCtx = createStateContext({ initialState: { count: 0 } }); + const actionCtx = createActionContext({ stateCtx }); + + await actionCtx.execute({ + action: "setState", + params: { statePath: "/count", value: 5 }, + }); + + expect(stateCtx.state.count).toBe(5); + }), + ); + + it( + "executes built-in pushState action", + component(async () => { + const stateCtx = createStateContext({ + initialState: { items: ["a", "b"] }, + }); + const actionCtx = createActionContext({ stateCtx }); + + await actionCtx.execute({ + action: "pushState", + params: { statePath: "/items", value: "c" }, + }); + + expect(stateCtx.state.items).toEqual(["a", "b", "c"]); + }), + ); + + it( + "pushState creates array if missing", + component(async () => { + const stateCtx = createStateContext(); + const actionCtx = createActionContext({ stateCtx }); + + 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 = createStateContext({ + initialState: { items: ["a", "b", "c"] }, + }); + const actionCtx = createActionContext({ stateCtx }); + + await actionCtx.execute({ + action: "removeState", + params: { statePath: "/items", index: 1 }, + }); + + expect(stateCtx.state.items).toEqual(["a", "c"]); + }), + ); + + it( + "executes push navigation action", + component(async () => { + const stateCtx = createStateContext({ + initialState: { currentScreen: "home" }, + }); + const actionCtx = createActionContext({ stateCtx }); + + await actionCtx.execute({ + action: "push", + params: { screen: "settings" }, + }); + + expect(stateCtx.get("/currentScreen")).toBe("settings"); + expect(stateCtx.get("/navStack")).toEqual(["home"]); + }), + ); + + it( + "executes pop navigation action", + component(async () => { + const stateCtx = createStateContext({ + initialState: { + currentScreen: "settings", + navStack: ["home"], + }, + }); + const actionCtx = createActionContext({ stateCtx }); + + await actionCtx.execute({ action: "pop" }); + + expect(stateCtx.get("/currentScreen")).toBe("home"); + expect(stateCtx.get("/navStack")).toEqual([]); + }), + ); + + it( + "executes custom handlers", + component(async () => { + const stateCtx = createStateContext(); + const customHandler = vi.fn().mockResolvedValue(undefined); + const actionCtx = createActionContext({ + stateCtx, + handlers: { + myAction: customHandler, + }, + }); + + await actionCtx.execute({ + action: "myAction", + params: { foo: "bar" }, + }); + + expect(customHandler).toHaveBeenCalledWith({ foo: "bar" }); + }), + ); + + it( + "warns when no handler registered", + component(async () => { + const stateCtx = createStateContext(); + const actionCtx = createActionContext({ stateCtx }); + 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", + component(async () => { + const stateCtx = createStateContext(); + let resolveHandler: () => void; + const slowHandler = vi.fn( + () => + new Promise((resolve) => { + resolveHandler = resolve; + }), + ); + const actionCtx = createActionContext({ + stateCtx, + handlers: { + slowAction: slowHandler, + }, + }); + + const executePromise = actionCtx.execute({ action: "slowAction" }); + + expect(actionCtx.loadingActions.has("slowAction")).toBe(true); + + resolveHandler!(); + await executePromise; + + expect(actionCtx.loadingActions.has("slowAction")).toBe(false); + }), + ); + + it( + "allows registering handlers dynamically", + component(async () => { + const stateCtx = createStateContext(); + const actionCtx = createActionContext({ stateCtx }); + 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 = createStateContext(); + const actionCtx = createActionContext({ + stateCtx, + validation: { + validateAll: () => false, + fieldStates: { + "/form/email": { + touched: true, + validated: true, + result: { valid: false, errors: ["Required"], checks: [] }, + }, + }, + }, + }); + + await actionCtx.execute({ action: "validateForm" }); + + expect(stateCtx.get("/formValidation")).toEqual({ + valid: false, + errors: { "/form/email": ["Required"] }, + }); + }), + ); + + it( + "validateForm defaults to warning when validation context is missing", + component(async () => { + const stateCtx = createStateContext(); + const actionCtx = createActionContext({ stateCtx }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await actionCtx.execute({ action: "validateForm" }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("validateForm action was dispatched"), + ); + warnSpy.mockRestore(); + }), + ); }); diff --git a/packages/svelte/src/contexts/state.svelte.ts b/packages/svelte/src/contexts/state.svelte.ts index a11c3836..50b02279 100644 --- a/packages/svelte/src/contexts/state.svelte.ts +++ b/packages/svelte/src/contexts/state.svelte.ts @@ -1,5 +1,11 @@ -import { getContext, setContext } from "svelte"; -import { getByPath, setByPath, type StateModel } from "@json-render/core"; +import { getContext, onDestroy, setContext } from "svelte"; +import { + createStateStore, + type StateModel, + type StateStore, + getByPath, +} from "@json-render/core"; +import { flattenToPointers } from "@json-render/core/store-utils"; const STATE_KEY = Symbol("json-render-state"); @@ -15,35 +21,117 @@ export interface StateContext { set: (path: string, value: unknown) => void; /** Update multiple values at once */ update: (updates: Record) => void; + /** Return the live state snapshot from the underlying store. */ + getSnapshot: () => StateModel; } export interface CurrentValue { current: T; } +type CreateStateContextOptions = { + store?: StateStore; + initialState?: StateModel; + onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; +}; + +type CreateStateContextInput = + | CreateStateContextOptions + | (() => CreateStateContextOptions); + /** - * Create a state context using Svelte 5 $state rune + * Create a state context using Svelte 5 $state rune. + * + * Supports two modes: + * - **Controlled**: pass a `store` (external adapter is source of truth) + * - **Uncontrolled**: omit `store` and optionally pass `initialState` / `onStateChange` */ export function createStateContext( - initialState: StateModel = {}, - onStateChange?: (path: string, value: unknown) => void, -): StateContext { - // Use $state for reactive state - creates deeply reactive object - let state = $state({ ...initialState }); + optionsOrGetter?: CreateStateContextInput, +): StateContext; +export function createStateContext( + optionsOrGetter: CreateStateContextInput = {}, +) { + const getOptions = + typeof optionsOrGetter === "function" + ? optionsOrGetter + : () => optionsOrGetter; + const initialOptions = getOptions(); + const { store: externalStore, initialState } = initialOptions; + const isControlled = !!externalStore; + const internalStore = !isControlled + ? createStateStore(initialState ?? {}) + : null; + const store: StateStore = externalStore ?? internalStore!; + + // Keep a reactive copy of the current store snapshot. + let state = $state.raw(store.getSnapshot()); + + const unsubscribe = store.subscribe(() => { + state = store.getSnapshot(); + }); + + onDestroy(unsubscribe); + + // In uncontrolled mode, support reactive initialState updates from options getter. + if (!isControlled) { + let prevFlat: Record = + initialState && Object.keys(initialState).length > 0 + ? flattenToPointers(initialState) + : {}; + + $effect.pre(() => { + const nextInitialState = getOptions().initialState; + if (!nextInitialState) return; + const nextFlat = + Object.keys(nextInitialState).length > 0 + ? flattenToPointers(nextInitialState) + : {}; + 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); + } + }); + } const ctx: StateContext = { get state() { return state; }, get: (path: string) => getByPath(state, path), + getSnapshot: () => store.getSnapshot(), set: (path: string, value: unknown) => { - setByPath(state, path, value); - onStateChange?.(path, value); + const onStateChange = getOptions().onStateChange; + const prev = store.getSnapshot(); + store.set(path, value); + if (!isControlled && store.getSnapshot() !== prev) { + onStateChange?.([{ path, value }]); + } }, update: (updates: Record) => { - for (const [path, value] of Object.entries(updates)) { - setByPath(state, path, value); - onStateChange?.(path, value); + const onStateChange = getOptions().onStateChange; + 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) { + onStateChange?.(changes); + } } }, }; diff --git a/packages/svelte/src/contexts/state.test.ts b/packages/svelte/src/contexts/state.test.ts index 0c98bcc8..f82f223c 100644 --- a/packages/svelte/src/contexts/state.test.ts +++ b/packages/svelte/src/contexts/state.test.ts @@ -1,114 +1,197 @@ import { describe, it, expect, vi } from "vitest"; -import { createStateContext, type StateContext } from "./state.svelte"; +import { mount, unmount } from "svelte"; +import { createStateStore } from "@json-render/core"; +import { createStateContext } from "./state.svelte"; + +function component(runTest: () => void) { + return () => { + const c = mount( + (() => { + runTest(); + }) as any, + { target: document.body }, + ); + unmount(c); + }; +} describe("createStateContext", () => { - it("provides initial state to consumers", () => { - const ctx = createStateContext({ user: { name: "John" } }); - - expect(ctx.state).toEqual({ user: { name: "John" } }); - }); - - it("provides empty object when no initial state", () => { - const ctx = createStateContext(); - - expect(ctx.state).toEqual({}); - }); + it( + "provides initial state to consumers", + component(() => { + const ctx = createStateContext({ + initialState: { user: { name: "John" } }, + }); + + expect(ctx.state).toEqual({ user: { name: "John" } }); + }), + ); + + it( + "provides empty object when no initial state", + component(() => { + const ctx = createStateContext(); + + expect(ctx.state).toEqual({}); + }), + ); }); describe("StateContext.get", () => { - it("retrieves values by path", () => { - const ctx = createStateContext({ user: { name: "John", age: 30 } }); - - expect(ctx.get("/user/name")).toBe("John"); - expect(ctx.get("/user/age")).toBe(30); - }); - - it("returns undefined for missing path", () => { - const ctx = createStateContext({ user: { name: "John" } }); - - expect(ctx.get("/user/email")).toBeUndefined(); - expect(ctx.get("/nonexistent")).toBeUndefined(); - }); + it( + "retrieves values by path", + component(() => { + const ctx = createStateContext({ + initialState: { user: { name: "John", age: 30 } }, + }); + + expect(ctx.get("/user/name")).toBe("John"); + expect(ctx.get("/user/age")).toBe(30); + }), + ); + + it( + "returns undefined for missing path", + component(() => { + const ctx = createStateContext({ + initialState: { user: { name: "John" } }, + }); + + expect(ctx.get("/user/email")).toBeUndefined(); + expect(ctx.get("/nonexistent")).toBeUndefined(); + }), + ); }); describe("StateContext.set", () => { - it("updates values at path", () => { - const ctx = createStateContext({ count: 0 }); - - ctx.set("/count", 5); - - expect(ctx.state.count).toBe(5); - }); - - it("creates nested paths", () => { - const ctx = createStateContext({}); - - ctx.set("/user/name", "Jane"); - - expect(ctx.get("/user/name")).toBe("Jane"); - }); - - it("calls onStateChange callback when state changes", () => { - const onStateChange = vi.fn(); - const ctx = createStateContext({ value: 1 }, onStateChange); - - ctx.set("/value", 2); - - expect(onStateChange).toHaveBeenCalledWith("/value", 2); - }); + it( + "updates values at path", + component(() => { + const ctx = createStateContext({ initialState: { count: 0 } }); + + ctx.set("/count", 5); + + expect(ctx.state.count).toBe(5); + }), + ); + + it( + "creates nested paths", + component(() => { + const ctx = createStateContext({}); + + ctx.set("/user/name", "Jane"); + + expect(ctx.get("/user/name")).toBe("Jane"); + }), + ); + + it( + "calls onStateChange callback with change entries", + component(() => { + const onStateChange = vi.fn(); + const ctx = createStateContext({ + initialState: { value: 1 }, + onStateChange, + }); + + ctx.set("/value", 2); + + expect(onStateChange).toHaveBeenCalledWith([ + { path: "/value", value: 2 }, + ]); + }), + ); }); describe("StateContext.update", () => { - it("handles multiple values at once", () => { - const ctx = createStateContext({ a: 1, b: 2 }); - - ctx.update({ "/a": 10, "/b": 20 }); - - expect(ctx.state.a).toBe(10); - expect(ctx.state.b).toBe(20); - }); - - it("calls onStateChange for each update", () => { - const onStateChange = vi.fn(); - const ctx = createStateContext({ x: 0, y: 0 }, onStateChange); - - ctx.update({ "/x": 1, "/y": 2 }); - - expect(onStateChange).toHaveBeenCalledWith("/x", 1); - expect(onStateChange).toHaveBeenCalledWith("/y", 2); - expect(onStateChange).toHaveBeenCalledTimes(2); - }); + it( + "handles multiple values at once", + component(() => { + const ctx = createStateContext({ initialState: { a: 1, b: 2 } }); + + ctx.update({ "/a": 10, "/b": 20 }); + + expect(ctx.state.a).toBe(10); + expect(ctx.state.b).toBe(20); + }), + ); + + it( + "calls onStateChange once with all changed updates", + component(() => { + const onStateChange = vi.fn(); + const ctx = createStateContext({ + initialState: { x: 0, y: 0 }, + onStateChange, + }); + + ctx.update({ "/x": 1, "/y": 2 }); + + expect(onStateChange).toHaveBeenCalledWith([ + { path: "/x", value: 1 }, + { path: "/y", value: 2 }, + ]); + expect(onStateChange).toHaveBeenCalledTimes(1); + }), + ); }); describe("StateContext nested paths", () => { - it("handles deeply nested state paths", () => { - const ctx = createStateContext({ - app: { - settings: { - theme: "light", - notifications: { enabled: true }, + it( + "handles deeply nested state paths", + component(() => { + const ctx = createStateContext({ + initialState: { + app: { + settings: { + theme: "light", + notifications: { enabled: true }, + }, + }, }, - }, - }); + }); - expect(ctx.get("/app/settings/theme")).toBe("light"); - expect(ctx.get("/app/settings/notifications/enabled")).toBe(true); + expect(ctx.get("/app/settings/theme")).toBe("light"); + expect(ctx.get("/app/settings/notifications/enabled")).toBe(true); - ctx.set("/app/settings/theme", "dark"); + ctx.set("/app/settings/theme", "dark"); - expect(ctx.get("/app/settings/theme")).toBe("dark"); - }); + expect(ctx.get("/app/settings/theme")).toBe("dark"); + }), + ); - it("handles array indices in paths", () => { - const ctx = createStateContext({ - items: ["a", "b", "c"], - }); + it( + "handles array indices in paths", + component(() => { + const ctx = createStateContext({ + initialState: { + items: ["a", "b", "c"], + }, + }); - expect(ctx.get("/items/0")).toBe("a"); - expect(ctx.get("/items/1")).toBe("b"); + expect(ctx.get("/items/0")).toBe("a"); + expect(ctx.get("/items/1")).toBe("b"); - ctx.set("/items/1", "B"); + ctx.set("/items/1", "B"); + + expect(ctx.get("/items/1")).toBe("B"); + }), + ); +}); - expect(ctx.get("/items/1")).toBe("B"); - }); +describe("controlled mode", () => { + it( + "reads and writes through external StateStore", + component(() => { + const store = createStateStore({ count: 1 }); + const onStateChange = vi.fn(); + const ctx = createStateContext({ store, onStateChange }); + + expect(ctx.get("/count")).toBe(1); + ctx.set("/count", 2); + expect(store.get("/count")).toBe(2); + expect(onStateChange).not.toHaveBeenCalled(); + }), + ); }); diff --git a/packages/svelte/src/contexts/validation.svelte.ts b/packages/svelte/src/contexts/validation.svelte.ts index 6961c156..ca8697e4 100644 --- a/packages/svelte/src/contexts/validation.svelte.ts +++ b/packages/svelte/src/contexts/validation.svelte.ts @@ -38,13 +38,26 @@ export interface ValidationContext { registerField: (path: string, config: ValidationConfig) => void; } +type CreateValidationContextOptions = { + stateCtx: StateContext; + customFunctions?: Record; +}; + +type CreateValidationContextInput = + | CreateValidationContextOptions + | (() => CreateValidationContextOptions); + /** * Create a validation context */ export function createValidationContext( - stateCtx: StateContext, - customFunctions: Record = {}, + optionsOrGetter: CreateValidationContextInput, ): ValidationContext { + const getOptions = + typeof optionsOrGetter === "function" + ? optionsOrGetter + : () => optionsOrGetter; + let fieldStates = $state>({}); let fieldConfigs = $state>({}); @@ -52,6 +65,7 @@ export function createValidationContext( path: string, config: ValidationConfig, ): ValidationResult => { + const { stateCtx, customFunctions = {} } = getOptions(); // Walk the nested state object using JSON Pointer segments const segments = path.split("/").filter(Boolean); let value: unknown = stateCtx.state; @@ -115,7 +129,9 @@ export function createValidationContext( }; const ctx: ValidationContext = { - customFunctions, + get customFunctions() { + return getOptions().customFunctions ?? {}; + }, get fieldStates() { return fieldStates; }, diff --git a/packages/svelte/src/contexts/visibility.test.ts b/packages/svelte/src/contexts/visibility.test.ts index 8407e3d9..1231ce15 100644 --- a/packages/svelte/src/contexts/visibility.test.ts +++ b/packages/svelte/src/contexts/visibility.test.ts @@ -1,89 +1,142 @@ import { describe, it, expect } from "vitest"; +import { mount, unmount } from "svelte"; import { createStateContext } from "./state.svelte"; import { createVisibilityContext } from "./visibility.svelte"; -describe("createVisibilityContext", () => { - it("provides isVisible function", () => { - const stateCtx = createStateContext({}); - const visCtx = createVisibilityContext(stateCtx); - - expect(typeof visCtx.isVisible).toBe("function"); - }); - - it("provides visibility context", () => { - const stateCtx = createStateContext({ value: true }); - const visCtx = createVisibilityContext(stateCtx); +function component(runTest: () => void) { + return () => { + const c = mount( + (() => { + runTest(); + }) as any, + { target: document.body }, + ); + unmount(c); + }; +} - expect(visCtx.ctx).toBeDefined(); - expect(visCtx.ctx.stateModel).toEqual({ value: true }); - }); +describe("createVisibilityContext", () => { + it( + "provides isVisible function", + component(() => { + const stateCtx = createStateContext(); + const visCtx = createVisibilityContext(stateCtx); + + expect(typeof visCtx.isVisible).toBe("function"); + }), + ); + + it( + "provides visibility context", + component(() => { + const stateCtx = createStateContext({ initialState: { value: true } }); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.ctx).toBeDefined(); + expect(visCtx.ctx.stateModel).toEqual({ value: true }); + }), + ); }); describe("isVisible", () => { - it("returns true for undefined condition", () => { - const stateCtx = createStateContext({}); - const visCtx = createVisibilityContext(stateCtx); - - expect(visCtx.isVisible(undefined)).toBe(true); - }); - - it("returns true for true condition", () => { - const stateCtx = createStateContext({}); - const visCtx = createVisibilityContext(stateCtx); - - expect(visCtx.isVisible(true)).toBe(true); - }); - - it("returns false for false condition", () => { - const stateCtx = createStateContext({}); - const visCtx = createVisibilityContext(stateCtx); - - expect(visCtx.isVisible(false)).toBe(false); - }); - - it("evaluates $state conditions against data", () => { - const stateCtx = createStateContext({ isLoggedIn: true }); - const visCtx = createVisibilityContext(stateCtx); - - expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(true); - - stateCtx.set("/isLoggedIn", false); - - expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(false); - }); - - it("evaluates equality conditions", () => { - const stateCtx = createStateContext({ tab: "home" }); - const visCtx = createVisibilityContext(stateCtx); - - expect(visCtx.isVisible({ $state: "/tab", eq: "home" })).toBe(true); - expect(visCtx.isVisible({ $state: "/tab", eq: "settings" })).toBe(false); - }); - - it("evaluates array conditions (implicit AND)", () => { - const stateCtx = createStateContext({ a: true, b: true, c: false }); - const visCtx = createVisibilityContext(stateCtx); - - expect(visCtx.isVisible([{ $state: "/a" }, { $state: "/b" }])).toBe(true); - - expect(visCtx.isVisible([{ $state: "/a" }, { $state: "/c" }])).toBe(false); - }); - - it("evaluates $and conditions", () => { - const stateCtx = createStateContext({ x: true, y: false }); - const visCtx = createVisibilityContext(stateCtx); - - expect( - visCtx.isVisible({ $and: [{ $state: "/x" }, { $state: "/y" }] }), - ).toBe(false); - }); - - it("evaluates $or conditions", () => { - const stateCtx = createStateContext({ x: true, y: false }); - const visCtx = createVisibilityContext(stateCtx); - - expect( - visCtx.isVisible({ $or: [{ $state: "/x" }, { $state: "/y" }] }), - ).toBe(true); - }); + it( + "returns true for undefined condition", + component(() => { + const stateCtx = createStateContext(); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible(undefined)).toBe(true); + }), + ); + + it( + "returns true for true condition", + component(() => { + const stateCtx = createStateContext(); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible(true)).toBe(true); + }), + ); + + it( + "returns false for false condition", + component(() => { + const stateCtx = createStateContext(); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible(false)).toBe(false); + }), + ); + + it( + "evaluates $state conditions against data", + component(() => { + const stateCtx = createStateContext({ + initialState: { isLoggedIn: true }, + }); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(true); + + stateCtx.set("/isLoggedIn", false); + + expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(false); + }), + ); + + it( + "evaluates equality conditions", + component(() => { + const stateCtx = createStateContext({ initialState: { tab: "home" } }); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible({ $state: "/tab", eq: "home" })).toBe(true); + expect(visCtx.isVisible({ $state: "/tab", eq: "settings" })).toBe(false); + }), + ); + + it( + "evaluates array conditions (implicit AND)", + component(() => { + const stateCtx = createStateContext({ + initialState: { a: true, b: true, c: false }, + }); + const visCtx = createVisibilityContext(stateCtx); + + expect(visCtx.isVisible([{ $state: "/a" }, { $state: "/b" }])).toBe(true); + + expect(visCtx.isVisible([{ $state: "/a" }, { $state: "/c" }])).toBe( + false, + ); + }), + ); + + it( + "evaluates $and conditions", + component(() => { + const stateCtx = createStateContext({ + initialState: { x: true, y: false }, + }); + const visCtx = createVisibilityContext(stateCtx); + + expect( + visCtx.isVisible({ $and: [{ $state: "/x" }, { $state: "/y" }] }), + ).toBe(false); + }), + ); + + it( + "evaluates $or conditions", + component(() => { + const stateCtx = createStateContext({ + initialState: { x: true, y: false }, + }); + const visCtx = createVisibilityContext(stateCtx); + + expect( + visCtx.isVisible({ $or: [{ $state: "/x" }, { $state: "/y" }] }), + ).toBe(true); + }), + ); }); diff --git a/packages/svelte/src/schema.ts b/packages/svelte/src/schema.ts index aaac353e..fd73e18a 100644 --- a/packages/svelte/src/schema.ts +++ b/packages/svelte/src/schema.ts @@ -51,6 +51,28 @@ export const schema = defineSchema( }), }), { + 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.", diff --git a/packages/svelte/src/types.ts b/packages/svelte/src/types.ts index ed74e300..0ceac492 100644 --- a/packages/svelte/src/types.ts +++ b/packages/svelte/src/types.ts @@ -1,5 +1,10 @@ import type { Component, Snippet } from "svelte"; -import type { UIElement, Spec, ActionHandler } from "@json-render/core"; +import type { + UIElement, + Spec, + ActionHandler, + StateStore, +} from "@json-render/core"; /** * Props passed to component renderers @@ -56,6 +61,11 @@ export interface RendererProps { export interface JSONUIProviderProps { /** Component registry */ registry: ComponentRegistry; + /** + * External store (controlled mode). When provided, `initialState` and + * `onStateChange` are ignored. + */ + store?: StateStore; /** Initial state model */ initialState?: Record; /** Action handlers */ @@ -67,8 +77,8 @@ export interface JSONUIProviderProps { string, (value: unknown, args?: Record) => boolean >; - /** Callback when state changes */ - onStateChange?: (path: string, value: unknown) => void; + /** Callback when state changes (uncontrolled mode) */ + onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; /** Children snippet */ children: Snippet; } @@ -79,12 +89,17 @@ export interface JSONUIProviderProps { export interface CreateRendererProps { /** The spec to render (AI-generated JSON) */ spec: Spec | null; - /** State context for dynamic values */ + /** + * 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 (e.g., from form inputs) */ - onStateChange?: (path: string, value: unknown) => void; + /** Callback when state changes (uncontrolled mode) */ + onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; /** Whether the spec is currently loading/streaming */ loading?: boolean; /** Fallback component for unknown types */ diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json index 2360df9b..8bb13ab4 100644 --- a/packages/svelte/tsconfig.json +++ b/packages/svelte/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@repo/typescript-config/base.json", + "extends": "@internal/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", diff --git a/vitest.config.mts b/vitest.config.mts index 9429c38f..131cb7ba 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -15,7 +15,6 @@ export default defineConfig({ alias: { react: path.resolve(__dirname, "node_modules/react"), "react-dom": path.resolve(__dirname, "node_modules/react-dom"), - svelte: path.resolve(__dirname, "node_modules/svelte"), vue: path.resolve(__dirname, "packages/vue/node_modules/vue"), }, }, From 7281a8ed48aa4d8c333c22b4b287d3ab81915969 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 26 Feb 2026 00:12:03 +0100 Subject: [PATCH 04/15] turn into provider components --- packages/svelte/src/ElementRenderer.svelte | 4 +- packages/svelte/src/JsonUIProvider.svelte | 39 +-- .../src/RendererWithProvider.test.svelte | 31 +- packages/svelte/src/RepeatChildren.svelte | 2 +- ...ctions.svelte.ts => ActionProvider.svelte} | 324 +++++++++--------- .../svelte/src/contexts/StateProvider.svelte | 181 ++++++++++ .../src/contexts/ValidationProvider.svelte | 198 +++++++++++ .../src/contexts/VisibilityProvider.svelte | 82 +++++ packages/svelte/src/contexts/actions.test.ts | 297 +++++++++------- packages/svelte/src/contexts/state.svelte.ts | 188 ---------- packages/svelte/src/contexts/state.test.ts | 226 ++++++------ .../svelte/src/contexts/validation.svelte.ts | 191 ----------- .../svelte/src/contexts/visibility.svelte.ts | 69 ---- .../svelte/src/contexts/visibility.test.ts | 159 +++++---- packages/svelte/src/index.ts | 17 +- 15 files changed, 1029 insertions(+), 979 deletions(-) rename packages/svelte/src/contexts/{actions.svelte.ts => ActionProvider.svelte} (53%) create mode 100644 packages/svelte/src/contexts/StateProvider.svelte create mode 100644 packages/svelte/src/contexts/ValidationProvider.svelte create mode 100644 packages/svelte/src/contexts/VisibilityProvider.svelte delete mode 100644 packages/svelte/src/contexts/state.svelte.ts delete mode 100644 packages/svelte/src/contexts/validation.svelte.ts delete mode 100644 packages/svelte/src/contexts/visibility.svelte.ts diff --git a/packages/svelte/src/ElementRenderer.svelte b/packages/svelte/src/ElementRenderer.svelte index d3ba299c..e7e15651 100644 --- a/packages/svelte/src/ElementRenderer.svelte +++ b/packages/svelte/src/ElementRenderer.svelte @@ -8,8 +8,8 @@ type PropResolutionContext, } from "@json-render/core"; import type { ComponentRegistry, ComponentRenderer } from "./types.js"; - import { getStateContext } from "./contexts/state.svelte.js"; - import { getActionContext } from "./contexts/actions.svelte.js"; + import { getStateContext } from "./contexts/StateProvider.svelte"; + import { getActionContext } from "./contexts/ActionProvider.svelte"; import { getRepeatScope } from "./contexts/repeat-scope.js"; import RepeatChildren from "./RepeatChildren.svelte"; import Self from "./ElementRenderer.svelte"; diff --git a/packages/svelte/src/JsonUIProvider.svelte b/packages/svelte/src/JsonUIProvider.svelte index 57de976a..b6b1e6e7 100644 --- a/packages/svelte/src/JsonUIProvider.svelte +++ b/packages/svelte/src/JsonUIProvider.svelte @@ -5,10 +5,10 @@ StateStore, ValidationFunction, } from "@json-render/core"; - import { createStateContext } from "./contexts/state.svelte.js"; - import { createVisibilityContext } from "./contexts/visibility.svelte.js"; - import { createActionContext } from "./contexts/actions.svelte.js"; - import { createValidationContext } from "./contexts/validation.svelte.js"; + import StateProvider from "./contexts/StateProvider.svelte"; + import VisibilityProvider from "./contexts/VisibilityProvider.svelte"; + import ValidationProvider from "./contexts/ValidationProvider.svelte"; + import ActionProvider from "./contexts/ActionProvider.svelte"; interface Props { store?: StateStore; @@ -29,27 +29,14 @@ onStateChange, children, }: Props = $props(); - - // Create and provide contexts - const stateCtx = createStateContext(() => ({ - store, - initialState, - onStateChange, - })); - - createVisibilityContext(stateCtx); - - const validationCtx = createValidationContext(() => ({ - stateCtx, - customFunctions: validationFunctions, - })); - - createActionContext(() => ({ - stateCtx, - handlers, - navigate, - validation: validationCtx, - })); -{@render children()} + + + + + {@render children()} + + + + diff --git a/packages/svelte/src/RendererWithProvider.test.svelte b/packages/svelte/src/RendererWithProvider.test.svelte index 8355cd51..3be231e9 100644 --- a/packages/svelte/src/RendererWithProvider.test.svelte +++ b/packages/svelte/src/RendererWithProvider.test.svelte @@ -1,10 +1,10 @@ - + + + + + + + + + diff --git a/packages/svelte/src/RepeatChildren.svelte b/packages/svelte/src/RepeatChildren.svelte index 97708a00..e4382171 100644 --- a/packages/svelte/src/RepeatChildren.svelte +++ b/packages/svelte/src/RepeatChildren.svelte @@ -2,7 +2,7 @@ import type { Spec, UIElement } from "@json-render/core"; import { getByPath } from "@json-render/core"; import type { ComponentRegistry, ComponentRenderer } from "./types.js"; - import { getStateContext } from "./contexts/state.svelte.js"; + import { getStateContext } from "./contexts/StateProvider.svelte"; import { setRepeatScope } from "./contexts/repeat-scope.js"; import ElementRenderer from "./ElementRenderer.svelte"; diff --git a/packages/svelte/src/contexts/actions.svelte.ts b/packages/svelte/src/contexts/ActionProvider.svelte similarity index 53% rename from packages/svelte/src/contexts/actions.svelte.ts rename to packages/svelte/src/contexts/ActionProvider.svelte index 24b1bbae..ef372b5d 100644 --- a/packages/svelte/src/contexts/actions.svelte.ts +++ b/packages/svelte/src/contexts/ActionProvider.svelte @@ -1,143 +1,169 @@ -import { getContext, setContext } from "svelte"; -import { - resolveAction, - executeAction, - type ActionBinding, - type ActionHandler, - type ActionConfirm, - type ResolvedAction, -} from "@json-render/core"; -import type { StateContext } from "./state.svelte"; -import type { FieldValidationState } from "./validation.svelte.js"; - -const ACTION_KEY = Symbol("json-render-actions"); - -/** - * 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. - */ -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(); + + + + +{@render children?.()} diff --git a/packages/svelte/src/contexts/StateProvider.svelte b/packages/svelte/src/contexts/StateProvider.svelte new file mode 100644 index 00000000..ad12e5b2 --- /dev/null +++ b/packages/svelte/src/contexts/StateProvider.svelte @@ -0,0 +1,181 @@ + + + + +{@render children?.()} diff --git a/packages/svelte/src/contexts/ValidationProvider.svelte b/packages/svelte/src/contexts/ValidationProvider.svelte new file mode 100644 index 00000000..ff58bb01 --- /dev/null +++ b/packages/svelte/src/contexts/ValidationProvider.svelte @@ -0,0 +1,198 @@ + + + + +{@render children?.()} diff --git a/packages/svelte/src/contexts/VisibilityProvider.svelte b/packages/svelte/src/contexts/VisibilityProvider.svelte new file mode 100644 index 00000000..c7650e44 --- /dev/null +++ b/packages/svelte/src/contexts/VisibilityProvider.svelte @@ -0,0 +1,82 @@ + + + + +{@render children?.()} diff --git a/packages/svelte/src/contexts/actions.test.ts b/packages/svelte/src/contexts/actions.test.ts index 3ba91cea..d6673be8 100644 --- a/packages/svelte/src/contexts/actions.test.ts +++ b/packages/svelte/src/contexts/actions.test.ts @@ -1,14 +1,51 @@ import { describe, it, expect, vi } from "vitest"; import { mount, unmount } from "svelte"; -import { createStateContext } from "./state.svelte"; -import { createActionContext } from "./actions.svelte"; - -function component(runTest: () => Promise) { +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( - (() => { - promise = runTest(); + ((_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 }, ); @@ -20,41 +57,45 @@ function component(runTest: () => Promise) { describe("createActionContext", () => { it( "executes built-in setState action", - component(async () => { - const stateCtx = createStateContext({ initialState: { count: 0 } }); - const actionCtx = createActionContext({ stateCtx }); - - await actionCtx.execute({ - action: "setState", - params: { statePath: "/count", value: 5 }, - }); - - expect(stateCtx.state.count).toBe(5); - }), + 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 = createStateContext({ - initialState: { items: ["a", "b"] }, - }); - const actionCtx = createActionContext({ stateCtx }); - - await actionCtx.execute({ - action: "pushState", - params: { statePath: "/items", value: "c" }, - }); - - expect(stateCtx.state.items).toEqual(["a", "b", "c"]); - }), + 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 = createStateContext(); - const actionCtx = createActionContext({ stateCtx }); + const stateCtx = getStateContext(); + const actionCtx = getActionContext(); await actionCtx.execute({ action: "pushState", @@ -67,83 +108,81 @@ describe("createActionContext", () => { it( "executes built-in removeState action", - component(async () => { - const stateCtx = createStateContext({ - initialState: { items: ["a", "b", "c"] }, - }); - const actionCtx = createActionContext({ stateCtx }); - - await actionCtx.execute({ - action: "removeState", - params: { statePath: "/items", index: 1 }, - }); - - expect(stateCtx.state.items).toEqual(["a", "c"]); - }), + 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 = createStateContext({ - initialState: { currentScreen: "home" }, - }); - const actionCtx = createActionContext({ stateCtx }); - - await actionCtx.execute({ - action: "push", - params: { screen: "settings" }, - }); - - expect(stateCtx.get("/currentScreen")).toBe("settings"); - expect(stateCtx.get("/navStack")).toEqual(["home"]); - }), + 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 = createStateContext({ - initialState: { - currentScreen: "settings", - navStack: ["home"], - }, - }); - const actionCtx = createActionContext({ stateCtx }); - - await actionCtx.execute({ action: "pop" }); - - expect(stateCtx.get("/currentScreen")).toBe("home"); - expect(stateCtx.get("/navStack")).toEqual([]); - }), + 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", - component(async () => { - const stateCtx = createStateContext(); + (() => { const customHandler = vi.fn().mockResolvedValue(undefined); - const actionCtx = createActionContext({ - stateCtx, - handlers: { - myAction: customHandler, - }, - }); + return component( + async () => { + const actionCtx = getActionContext(); - await actionCtx.execute({ - action: "myAction", - params: { foo: "bar" }, - }); + await actionCtx.execute({ + action: "myAction", + params: { foo: "bar" }, + }); - expect(customHandler).toHaveBeenCalledWith({ foo: "bar" }); - }), + expect(customHandler).toHaveBeenCalledWith({ foo: "bar" }); + }, + { handlers: { myAction: customHandler } }, + ); + })(), ); it( "warns when no handler registered", component(async () => { - const stateCtx = createStateContext(); - const actionCtx = createActionContext({ stateCtx }); + const actionCtx = getActionContext(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); await actionCtx.execute({ action: "unknownAction" }); @@ -157,8 +196,7 @@ describe("createActionContext", () => { it( "tracks loading state for actions", - component(async () => { - const stateCtx = createStateContext(); + (() => { let resolveHandler: () => void; const slowHandler = vi.fn( () => @@ -166,29 +204,31 @@ describe("createActionContext", () => { resolveHandler = resolve; }), ); - const actionCtx = createActionContext({ - stateCtx, - handlers: { - slowAction: slowHandler, - }, - }); + return component( + async () => { + const actionCtx = getActionContext(); + const executePromise = actionCtx.execute({ action: "slowAction" }); - const executePromise = actionCtx.execute({ action: "slowAction" }); + expect(actionCtx.loadingActions.has("slowAction")).toBe(true); - expect(actionCtx.loadingActions.has("slowAction")).toBe(true); + resolveHandler!(); + await executePromise; - resolveHandler!(); - await executePromise; - - expect(actionCtx.loadingActions.has("slowAction")).toBe(false); - }), + expect(actionCtx.loadingActions.has("slowAction")).toBe(false); + }, + { + handlers: { + slowAction: slowHandler, + }, + }, + ); + })(), ); it( "allows registering handlers dynamically", component(async () => { - const stateCtx = createStateContext(); - const actionCtx = createActionContext({ stateCtx }); + const actionCtx = getActionContext(); const dynamicHandler = vi.fn(); actionCtx.registerHandler("dynamicAction", dynamicHandler); @@ -200,36 +240,31 @@ describe("createActionContext", () => { it( "executes validateForm and writes result to /formValidation", - component(async () => { - const stateCtx = createStateContext(); - const actionCtx = createActionContext({ - stateCtx, - validation: { - validateAll: () => false, - fieldStates: { - "/form/email": { - touched: true, - validated: true, - result: { valid: false, errors: ["Required"], checks: [] }, - }, - }, - }, - }); - - await actionCtx.execute({ action: "validateForm" }); - - expect(stateCtx.get("/formValidation")).toEqual({ - valid: false, - errors: { "/form/email": ["Required"] }, - }); - }), + 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 stateCtx = createStateContext(); - const actionCtx = createActionContext({ stateCtx }); + const actionCtx = getActionContext(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); await actionCtx.execute({ action: "validateForm" }); diff --git a/packages/svelte/src/contexts/state.svelte.ts b/packages/svelte/src/contexts/state.svelte.ts deleted file mode 100644 index 50b02279..00000000 --- a/packages/svelte/src/contexts/state.svelte.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { getContext, onDestroy, setContext } from "svelte"; -import { - createStateStore, - type StateModel, - type StateStore, - getByPath, -} from "@json-render/core"; -import { flattenToPointers } from "@json-render/core/store-utils"; - -const STATE_KEY = Symbol("json-render-state"); - -/** - * State context value - */ -export interface StateContext { - /** The current state model (reactive) */ - readonly state: StateModel; - /** 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. */ - getSnapshot: () => StateModel; -} - -export interface CurrentValue { - current: T; -} - -type CreateStateContextOptions = { - store?: StateStore; - initialState?: StateModel; - onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; -}; - -type CreateStateContextInput = - | CreateStateContextOptions - | (() => CreateStateContextOptions); - -/** - * Create a state context using Svelte 5 $state rune. - * - * Supports two modes: - * - **Controlled**: pass a `store` (external adapter is source of truth) - * - **Uncontrolled**: omit `store` and optionally pass `initialState` / `onStateChange` - */ -export function createStateContext( - optionsOrGetter?: CreateStateContextInput, -): StateContext; -export function createStateContext( - optionsOrGetter: CreateStateContextInput = {}, -) { - const getOptions = - typeof optionsOrGetter === "function" - ? optionsOrGetter - : () => optionsOrGetter; - const initialOptions = getOptions(); - const { store: externalStore, initialState } = initialOptions; - const isControlled = !!externalStore; - const internalStore = !isControlled - ? createStateStore(initialState ?? {}) - : null; - const store: StateStore = externalStore ?? internalStore!; - - // Keep a reactive copy of the current store snapshot. - let state = $state.raw(store.getSnapshot()); - - const unsubscribe = store.subscribe(() => { - state = store.getSnapshot(); - }); - - onDestroy(unsubscribe); - - // In uncontrolled mode, support reactive initialState updates from options getter. - if (!isControlled) { - let prevFlat: Record = - initialState && Object.keys(initialState).length > 0 - ? flattenToPointers(initialState) - : {}; - - $effect.pre(() => { - const nextInitialState = getOptions().initialState; - if (!nextInitialState) return; - const nextFlat = - Object.keys(nextInitialState).length > 0 - ? flattenToPointers(nextInitialState) - : {}; - 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); - } - }); - } - - const ctx: StateContext = { - get state() { - return state; - }, - get: (path: string) => getByPath(state, path), - getSnapshot: () => store.getSnapshot(), - set: (path: string, value: unknown) => { - const onStateChange = getOptions().onStateChange; - const prev = store.getSnapshot(); - store.set(path, value); - if (!isControlled && store.getSnapshot() !== prev) { - onStateChange?.([{ path, value }]); - } - }, - update: (updates: Record) => { - const onStateChange = getOptions().onStateChange; - 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) { - onStateChange?.(changes); - } - } - }, - }; - - setContext(STATE_KEY, ctx); - return ctx; -} - -/** - * Get the state context from component tree - */ -export function getStateContext(): StateContext { - const ctx = getContext(STATE_KEY); - if (!ctx) { - throw new Error("getStateContext must be called within a JsonUIProvider"); - } - return ctx; -} - -/** - * Convenience helper to read a value from the state context - */ -export function getStateValue(path: string): CurrentValue { - const context = getStateContext(); - return { - get current() { - return context.get(path); - }, - set current(value: unknown) { - context.set(path, value); - }, - }; -} - -/** - * Two-way helper for `$bindState` / `$bindItem` bindings. - * Mirrors `useBoundProp` from React packages. - */ -export function getBoundProp( - propValue: () => T | undefined, - bindingPath: string | undefined, -): CurrentValue { - const context = getStateContext(); - return { - get current() { - return propValue(); - }, - set current(value: T | undefined) { - if (bindingPath) { - context.set(bindingPath, value); - } - }, - }; -} diff --git a/packages/svelte/src/contexts/state.test.ts b/packages/svelte/src/contexts/state.test.ts index f82f223c..62f5a213 100644 --- a/packages/svelte/src/contexts/state.test.ts +++ b/packages/svelte/src/contexts/state.test.ts @@ -1,13 +1,25 @@ import { describe, it, expect, vi } from "vitest"; import { mount, unmount } from "svelte"; import { createStateStore } from "@json-render/core"; -import { createStateContext } from "./state.svelte"; - -function component(runTest: () => void) { +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( - (() => { - runTest(); + ((_anchor: any) => { + (StateProvider as any)(_anchor, { + ...props, + children: (() => { + runTest(); + }) as any, + }); }) as any, { target: document.body }, ); @@ -15,23 +27,22 @@ function component(runTest: () => void) { }; } -describe("createStateContext", () => { +describe("StateProvider", () => { it( "provides initial state to consumers", - component(() => { - const ctx = createStateContext({ - initialState: { user: { name: "John" } }, - }); - - expect(ctx.state).toEqual({ user: { name: "John" } }); - }), + 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 = createStateContext(); - + const ctx = getStateContext(); expect(ctx.state).toEqual({}); }), ); @@ -40,108 +51,114 @@ describe("createStateContext", () => { describe("StateContext.get", () => { it( "retrieves values by path", - component(() => { - const ctx = createStateContext({ - initialState: { user: { name: "John", age: 30 } }, - }); - - expect(ctx.get("/user/name")).toBe("John"); - expect(ctx.get("/user/age")).toBe(30); - }), + 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 = createStateContext({ - initialState: { user: { name: "John" } }, - }); - - expect(ctx.get("/user/email")).toBeUndefined(); - expect(ctx.get("/nonexistent")).toBeUndefined(); - }), + 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 = createStateContext({ initialState: { count: 0 } }); - - ctx.set("/count", 5); - - expect(ctx.state.count).toBe(5); - }), + component( + () => { + const ctx = getStateContext(); + ctx.set("/count", 5); + expect(ctx.state.count).toBe(5); + }, + { initialState: { count: 0 } }, + ), ); it( "creates nested paths", component(() => { - const ctx = createStateContext({}); - + const ctx = getStateContext(); ctx.set("/user/name", "Jane"); - expect(ctx.get("/user/name")).toBe("Jane"); }), ); it( "calls onStateChange callback with change entries", - component(() => { - const onStateChange = vi.fn(); - const ctx = createStateContext({ + component( + () => { + const ctx = getStateContext(); + ctx.set("/value", 2); + }, + { initialState: { value: 1 }, - onStateChange, - }); - - ctx.set("/value", 2); - - expect(onStateChange).toHaveBeenCalledWith([ - { path: "/value", value: 2 }, - ]); - }), + onStateChange: vi.fn((changes) => { + expect(changes).toEqual([{ path: "/value", value: 2 }]); + }), + }, + ), ); }); describe("StateContext.update", () => { it( "handles multiple values at once", - component(() => { - const ctx = createStateContext({ initialState: { a: 1, b: 2 } }); - - ctx.update({ "/a": 10, "/b": 20 }); - - expect(ctx.state.a).toBe(10); - expect(ctx.state.b).toBe(20); - }), + 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 onStateChange = vi.fn(); - const ctx = createStateContext({ + component( + () => { + const ctx = getStateContext(); + ctx.update({ "/x": 1, "/y": 2 }); + }, + { initialState: { x: 0, y: 0 }, - onStateChange, - }); - - ctx.update({ "/x": 1, "/y": 2 }); - - expect(onStateChange).toHaveBeenCalledWith([ - { path: "/x", value: 1 }, - { path: "/y", value: 2 }, - ]); - expect(onStateChange).toHaveBeenCalledTimes(1); - }), + 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 = createStateContext({ + 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: { @@ -150,48 +167,41 @@ describe("StateContext nested paths", () => { }, }, }, - }); - - 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"); - }), + }, + ), ); it( "handles array indices in paths", - component(() => { - const ctx = createStateContext({ - initialState: { - items: ["a", "b", "c"], - }, - }); - - 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"); - }), + 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", - component(() => { + (() => { const store = createStateStore({ count: 1 }); const onStateChange = vi.fn(); - const ctx = createStateContext({ store, onStateChange }); - - expect(ctx.get("/count")).toBe(1); - ctx.set("/count", 2); - expect(store.get("/count")).toBe(2); - expect(onStateChange).not.toHaveBeenCalled(); - }), + 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 }, + ); + })(), ); }); diff --git a/packages/svelte/src/contexts/validation.svelte.ts b/packages/svelte/src/contexts/validation.svelte.ts deleted file mode 100644 index ca8697e4..00000000 --- a/packages/svelte/src/contexts/validation.svelte.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { getContext, setContext } from "svelte"; -import { - runValidation, - type ValidationConfig, - type ValidationFunction, - type ValidationResult, -} from "@json-render/core"; -import type { StateContext } from "./state.svelte"; - -const VALIDATION_KEY = Symbol("json-render-validation"); - -/** - * Field validation state - */ -export interface FieldValidationState { - touched: boolean; - validated: boolean; - result: ValidationResult | null; -} - -/** - * Validation context value - */ -export interface ValidationContext { - /** 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; -} - -type CreateValidationContextOptions = { - stateCtx: StateContext; - customFunctions?: Record; -}; - -type CreateValidationContextInput = - | CreateValidationContextOptions - | (() => CreateValidationContextOptions); - -/** - * Create a validation context - */ -export function createValidationContext( - optionsOrGetter: CreateValidationContextInput, -): ValidationContext { - const getOptions = - typeof optionsOrGetter === "function" - ? optionsOrGetter - : () => optionsOrGetter; - - let fieldStates = $state>({}); - let fieldConfigs = $state>({}); - - const validate = ( - path: string, - config: ValidationConfig, - ): ValidationResult => { - const { stateCtx, customFunctions = {} } = getOptions(); - // Walk the nested state object using JSON Pointer segments - const segments = path.split("/").filter(Boolean); - let value: unknown = stateCtx.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: stateCtx.state, - customFunctions, - }); - - fieldStates = { - ...fieldStates, - [path]: { - touched: fieldStates[path]?.touched ?? true, - validated: true, - result, - }, - }; - - return result; - }; - - const touch = (path: string): void => { - fieldStates = { - ...fieldStates, - [path]: { - ...fieldStates[path], - touched: true, - validated: fieldStates[path]?.validated ?? false, - result: fieldStates[path]?.result ?? null, - }, - }; - }; - - const clear = (path: string): void => { - const { [path]: _, ...rest } = fieldStates; - fieldStates = rest; - }; - - const validateAll = (): boolean => { - let allValid = true; - for (const [path, config] of Object.entries(fieldConfigs)) { - const result = validate(path, config); - if (!result.valid) { - allValid = false; - } - } - return allValid; - }; - - const registerField = (path: string, config: ValidationConfig): void => { - fieldConfigs = { ...fieldConfigs, [path]: config }; - }; - - const ctx: ValidationContext = { - get customFunctions() { - return getOptions().customFunctions ?? {}; - }, - get fieldStates() { - return fieldStates; - }, - validate, - touch, - clear, - validateAll, - registerField, - }; - - setContext(VALIDATION_KEY, ctx); - return ctx; -} - -/** - * Get the validation context from component tree - */ -export function getValidationContext(): ValidationContext { - const ctx = getContext(VALIDATION_KEY); - if (!ctx) { - throw new Error( - "getValidationContext must be called within a JsonUIProvider", - ); - } - return ctx; -} - -/** - * Helper to get field validation state - */ -export function getFieldValidation( - ctx: ValidationContext, - path: string, - config?: ValidationConfig, -): { - state: FieldValidationState; - validate: () => ValidationResult; - touch: () => void; - clear: () => void; - errors: string[]; - isValid: boolean; -} { - const state = ctx.fieldStates[path] ?? { - touched: false, - validated: false, - result: null, - }; - - return { - state, - validate: () => ctx.validate(path, config ?? { checks: [] }), - touch: () => ctx.touch(path), - clear: () => ctx.clear(path), - errors: state.result?.errors ?? [], - isValid: state.result?.valid ?? true, - }; -} diff --git a/packages/svelte/src/contexts/visibility.svelte.ts b/packages/svelte/src/contexts/visibility.svelte.ts deleted file mode 100644 index 1853dca3..00000000 --- a/packages/svelte/src/contexts/visibility.svelte.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { getContext, setContext } from "svelte"; -import { - evaluateVisibility, - type VisibilityCondition, - type VisibilityContext as CoreVisibilityContext, -} from "@json-render/core"; -import type { StateContext } from "./state.svelte"; - -const VISIBILITY_KEY = Symbol("json-render-visibility"); - -/** - * Visibility context value - */ -export interface VisibilityContext { - /** Evaluate a visibility condition */ - isVisible: (condition: VisibilityCondition | undefined) => boolean; - /** The underlying visibility context (for advanced use) */ - ctx: CoreVisibilityContext; -} - -export interface CurrentValue { - readonly current: T; -} - -/** - * Create a visibility context that reads from the state context - */ -export function createVisibilityContext( - stateCtx: StateContext, -): VisibilityContext { - const ctx: VisibilityContext = { - get ctx(): CoreVisibilityContext { - return { stateModel: stateCtx.state }; - }, - isVisible: (condition: VisibilityCondition | undefined) => { - return evaluateVisibility(condition, { stateModel: stateCtx.state }); - }, - }; - - setContext(VISIBILITY_KEY, ctx); - return ctx; -} - -/** - * Get the visibility context from component tree - */ -export function getVisibilityContext(): VisibilityContext { - const ctx = getContext(VISIBILITY_KEY); - if (!ctx) { - throw new Error( - "getVisibilityContext must be called within a JsonUIProvider", - ); - } - return ctx; -} - -/** - * Convenience helper to evaluate visibility from context - */ -export function isVisible( - condition: VisibilityCondition | undefined, -): CurrentValue { - const context = getVisibilityContext(); - return { - get current() { - return context.isVisible(condition); - }, - }; -} diff --git a/packages/svelte/src/contexts/visibility.test.ts b/packages/svelte/src/contexts/visibility.test.ts index 1231ce15..e481d8b0 100644 --- a/packages/svelte/src/contexts/visibility.test.ts +++ b/packages/svelte/src/contexts/visibility.test.ts @@ -1,13 +1,27 @@ import { describe, it, expect } from "vitest"; import { mount, unmount } from "svelte"; -import { createStateContext } from "./state.svelte"; -import { createVisibilityContext } from "./visibility.svelte"; - -function component(runTest: () => void) { +import StateProvider, { getStateContext } from "./StateProvider.svelte"; +import VisibilityProvider, { + getVisibilityContext, +} from "./VisibilityProvider.svelte"; + +function component( + runTest: () => void, + initialState: Record = {}, +) { return () => { const c = mount( - (() => { - runTest(); + ((_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 }, ); @@ -15,12 +29,11 @@ function component(runTest: () => void) { }; } -describe("createVisibilityContext", () => { +describe("VisibilityProvider", () => { it( "provides isVisible function", component(() => { - const stateCtx = createStateContext(); - const visCtx = createVisibilityContext(stateCtx); + const visCtx = getVisibilityContext(); expect(typeof visCtx.isVisible).toBe("function"); }), @@ -28,13 +41,15 @@ describe("createVisibilityContext", () => { it( "provides visibility context", - component(() => { - const stateCtx = createStateContext({ initialState: { value: true } }); - const visCtx = createVisibilityContext(stateCtx); - - expect(visCtx.ctx).toBeDefined(); - expect(visCtx.ctx.stateModel).toEqual({ value: true }); - }), + component( + () => { + const visCtx = getVisibilityContext(); + + expect(visCtx.ctx).toBeDefined(); + expect(visCtx.ctx.stateModel).toEqual({ value: true }); + }, + { value: true }, + ), ); }); @@ -42,8 +57,7 @@ describe("isVisible", () => { it( "returns true for undefined condition", component(() => { - const stateCtx = createStateContext(); - const visCtx = createVisibilityContext(stateCtx); + const visCtx = getVisibilityContext(); expect(visCtx.isVisible(undefined)).toBe(true); }), @@ -52,8 +66,7 @@ describe("isVisible", () => { it( "returns true for true condition", component(() => { - const stateCtx = createStateContext(); - const visCtx = createVisibilityContext(stateCtx); + const visCtx = getVisibilityContext(); expect(visCtx.isVisible(true)).toBe(true); }), @@ -62,8 +75,7 @@ describe("isVisible", () => { it( "returns false for false condition", component(() => { - const stateCtx = createStateContext(); - const visCtx = createVisibilityContext(stateCtx); + const visCtx = getVisibilityContext(); expect(visCtx.isVisible(false)).toBe(false); }), @@ -71,72 +83,79 @@ describe("isVisible", () => { it( "evaluates $state conditions against data", - component(() => { - const stateCtx = createStateContext({ - initialState: { isLoggedIn: true }, - }); - const visCtx = createVisibilityContext(stateCtx); + component( + () => { + const stateCtx = getStateContext(); + const visCtx = getVisibilityContext(); - expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(true); + expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(true); - stateCtx.set("/isLoggedIn", false); + stateCtx.set("/isLoggedIn", false); - expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(false); - }), + expect(visCtx.isVisible({ $state: "/isLoggedIn" })).toBe(false); + }, + { isLoggedIn: true }, + ), ); it( "evaluates equality conditions", - component(() => { - const stateCtx = createStateContext({ initialState: { tab: "home" } }); - const visCtx = createVisibilityContext(stateCtx); - - expect(visCtx.isVisible({ $state: "/tab", eq: "home" })).toBe(true); - expect(visCtx.isVisible({ $state: "/tab", eq: "settings" })).toBe(false); - }), + 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 stateCtx = createStateContext({ - initialState: { a: true, b: true, c: false }, - }); - const visCtx = createVisibilityContext(stateCtx); - - expect(visCtx.isVisible([{ $state: "/a" }, { $state: "/b" }])).toBe(true); - - expect(visCtx.isVisible([{ $state: "/a" }, { $state: "/c" }])).toBe( - false, - ); - }), + 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 stateCtx = createStateContext({ - initialState: { x: true, y: false }, - }); - const visCtx = createVisibilityContext(stateCtx); - - expect( - visCtx.isVisible({ $and: [{ $state: "/x" }, { $state: "/y" }] }), - ).toBe(false); - }), + component( + () => { + const visCtx = getVisibilityContext(); + + expect( + visCtx.isVisible({ $and: [{ $state: "/x" }, { $state: "/y" }] }), + ).toBe(false); + }, + { x: true, y: false }, + ), ); it( "evaluates $or conditions", - component(() => { - const stateCtx = createStateContext({ - initialState: { x: true, y: false }, - }); - const visCtx = createVisibilityContext(stateCtx); - - expect( - visCtx.isVisible({ $or: [{ $state: "/x" }, { $state: "/y" }] }), - ).toBe(true); - }), + component( + () => { + const visCtx = getVisibilityContext(); + + expect( + visCtx.isVisible({ $or: [{ $state: "/x" }, { $state: "/y" }] }), + ).toBe(true); + }, + { x: true, y: false }, + ), ); }); diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index fb6c8deb..f3987790 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -3,35 +3,36 @@ // ============================================================================= export { - createStateContext, + default as StateProvider, getStateContext, getStateValue, getBoundProp, type StateContext, -} from "./contexts/state.svelte.js"; +} from "./contexts/StateProvider.svelte"; export { - createVisibilityContext, + default as VisibilityProvider, getVisibilityContext, isVisible, type VisibilityContext, -} from "./contexts/visibility.svelte.js"; +} from "./contexts/VisibilityProvider.svelte"; export { - createActionContext, + default as ActionProvider, getActionContext, getAction, type ActionContext, type PendingConfirmation, -} from "./contexts/actions.svelte.js"; +} from "./contexts/ActionProvider.svelte"; export { - createValidationContext, + default as ValidationProvider, getValidationContext, + getOptionalValidationContext, getFieldValidation, type ValidationContext, type FieldValidationState, -} from "./contexts/validation.svelte.js"; +} from "./contexts/ValidationProvider.svelte"; export { setRepeatScope, From fc25693194f254e51eb34952452a145e4efc2050 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 26 Feb 2026 14:52:30 +0100 Subject: [PATCH 05/15] fixes --- packages/svelte/src/ElementRenderer.svelte | 2 +- packages/svelte/src/RepeatChildren.svelte | 29 +++++------ .../svelte/src/contexts/ActionProvider.svelte | 19 +++---- .../src/contexts/RepeatScopeProvider.svelte | 49 +++++++++++++++++++ .../svelte/src/contexts/StateProvider.svelte | 2 +- .../src/contexts/ValidationProvider.svelte | 4 +- packages/svelte/src/contexts/repeat-scope.ts | 29 ----------- packages/svelte/src/index.ts | 4 +- packages/svelte/src/types.ts | 5 +- 9 files changed, 79 insertions(+), 64 deletions(-) create mode 100644 packages/svelte/src/contexts/RepeatScopeProvider.svelte delete mode 100644 packages/svelte/src/contexts/repeat-scope.ts diff --git a/packages/svelte/src/ElementRenderer.svelte b/packages/svelte/src/ElementRenderer.svelte index e7e15651..e6179463 100644 --- a/packages/svelte/src/ElementRenderer.svelte +++ b/packages/svelte/src/ElementRenderer.svelte @@ -10,7 +10,7 @@ import type { ComponentRegistry, ComponentRenderer } from "./types.js"; import { getStateContext } from "./contexts/StateProvider.svelte"; import { getActionContext } from "./contexts/ActionProvider.svelte"; - import { getRepeatScope } from "./contexts/repeat-scope.js"; + import { getRepeatScope } from "./contexts/RepeatScopeProvider.svelte"; import RepeatChildren from "./RepeatChildren.svelte"; import Self from "./ElementRenderer.svelte"; diff --git a/packages/svelte/src/RepeatChildren.svelte b/packages/svelte/src/RepeatChildren.svelte index e4382171..180d1a49 100644 --- a/packages/svelte/src/RepeatChildren.svelte +++ b/packages/svelte/src/RepeatChildren.svelte @@ -3,7 +3,7 @@ import { getByPath } from "@json-render/core"; import type { ComponentRegistry, ComponentRenderer } from "./types.js"; import { getStateContext } from "./contexts/StateProvider.svelte"; - import { setRepeatScope } from "./contexts/repeat-scope.js"; + import RepeatScopeProvider from "./contexts/RepeatScopeProvider.svelte"; import ElementRenderer from "./ElementRenderer.svelte"; interface Props { @@ -28,20 +28,21 @@ {#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}`} - {setRepeatScope({ item: itemValue, index, basePath })} {#if element.children} - {#each element.children as childKey (childKey)} - {#if spec.elements[childKey]} - - {:else if !loading} - - {/if} - {/each} + + {#each element.children as childKey (childKey)} + {#if spec.elements[childKey]} + + {:else if !loading} + + {/if} + {/each} + {/if} {/each} diff --git a/packages/svelte/src/contexts/ActionProvider.svelte b/packages/svelte/src/contexts/ActionProvider.svelte index ef372b5d..48a4c1cd 100644 --- a/packages/svelte/src/contexts/ActionProvider.svelte +++ b/packages/svelte/src/contexts/ActionProvider.svelte @@ -145,6 +145,7 @@ } from "@json-render/core"; import { getStateContext } from "./StateProvider.svelte"; import { getOptionalValidationContext } from "./ValidationProvider.svelte"; + import { SvelteSet } from "svelte/reactivity"; interface Props { handlers?: Record; @@ -157,9 +158,9 @@ const stateCtx = getStateContext(); const validation = getOptionalValidationContext(); - let registeredHandlers = $state>({}); - let loadingActions = $state>(new Set()); - let pendingConfirmation = $state(null); + let registeredHandlers = $state.raw>({}); + let loadingActions = new SvelteSet(); + let pendingConfirmation = $state.raw(null); const execute = async (binding: CoreActionBinding): Promise => { const resolved = resolveAction(binding, stateCtx.getSnapshot()); @@ -280,7 +281,7 @@ }, }; }).then(async () => { - loadingActions = new Set(loadingActions).add(resolved.action); + loadingActions.add(resolved.action); try { await executeAction({ action: resolved, @@ -293,14 +294,12 @@ }, }); } finally { - const next = new Set(loadingActions); - next.delete(resolved.action); - loadingActions = next; + loadingActions.delete(resolved.action); } }); } - loadingActions = new Set(loadingActions).add(resolved.action); + loadingActions.add(resolved.action); try { await executeAction({ action: resolved, @@ -313,9 +312,7 @@ }, }); } finally { - const next = new Set(loadingActions); - next.delete(resolved.action); - loadingActions = next; + loadingActions.delete(resolved.action); } }; diff --git a/packages/svelte/src/contexts/RepeatScopeProvider.svelte b/packages/svelte/src/contexts/RepeatScopeProvider.svelte new file mode 100644 index 00000000..c2063ccc --- /dev/null +++ b/packages/svelte/src/contexts/RepeatScopeProvider.svelte @@ -0,0 +1,49 @@ + + + + +{@render children()} diff --git a/packages/svelte/src/contexts/StateProvider.svelte b/packages/svelte/src/contexts/StateProvider.svelte index ad12e5b2..ee0d074a 100644 --- a/packages/svelte/src/contexts/StateProvider.svelte +++ b/packages/svelte/src/contexts/StateProvider.svelte @@ -99,7 +99,7 @@ } // Keep a reactive copy of the current store snapshot. - let model: CoreStateModel = activeStore().getSnapshot(); + let model: CoreStateModel = $state.raw(activeStore().getSnapshot()); $effect(() => { const currentStore = activeStore(); diff --git a/packages/svelte/src/contexts/ValidationProvider.svelte b/packages/svelte/src/contexts/ValidationProvider.svelte index ff58bb01..7fc9b1ca 100644 --- a/packages/svelte/src/contexts/ValidationProvider.svelte +++ b/packages/svelte/src/contexts/ValidationProvider.svelte @@ -110,8 +110,8 @@ const stateCtx = getStateContext(); - let fieldStates = $state>({}); - let fieldConfigs = $state>({}); + let fieldStates = $state.raw>({}); + let fieldConfigs = $state.raw>({}); const validate = ( path: string, diff --git a/packages/svelte/src/contexts/repeat-scope.ts b/packages/svelte/src/contexts/repeat-scope.ts deleted file mode 100644 index 71d0448c..00000000 --- a/packages/svelte/src/contexts/repeat-scope.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getContext, setContext } from "svelte"; - -const REPEAT_SCOPE_KEY = Symbol("json-render-repeat-scope"); - -/** - * 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") */ - basePath: string; -} - -/** - * Set the repeat scope in component tree - */ -export function setRepeatScope(scope: RepeatScopeValue): void { - setContext(REPEAT_SCOPE_KEY, scope); -} - -/** - * Get the current repeat scope (or null if not inside a repeated element) - */ -export function getRepeatScope(): RepeatScopeValue | null { - return getContext(REPEAT_SCOPE_KEY) ?? null; -} diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index f3987790..b119523f 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -35,10 +35,10 @@ export { } from "./contexts/ValidationProvider.svelte"; export { - setRepeatScope, + default as RepeatScopeProvider, getRepeatScope, type RepeatScopeValue, -} from "./contexts/repeat-scope.js"; +} from "./contexts/RepeatScopeProvider.svelte"; // ============================================================================= // Schema diff --git a/packages/svelte/src/types.ts b/packages/svelte/src/types.ts index 0ceac492..44ecf962 100644 --- a/packages/svelte/src/types.ts +++ b/packages/svelte/src/types.ts @@ -36,10 +36,7 @@ export type ComponentRenderer

> = Component< * Registry of component renderers. * Maps component type names to Svelte components. */ -export type ComponentRegistry = Record< - string, - ComponentRenderer | undefined ->; +export type ComponentRegistry = Record>; /** * Props for the Renderer component From 58a72324dbef77f3917b94a3866464e7b4716c35 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 26 Feb 2026 16:30:29 +0100 Subject: [PATCH 06/15] examples --- README.md | 3 +- examples/svelte-chat/.gitignore | 23 + examples/svelte-chat/.npmrc | 1 + examples/svelte-chat/README.md | 42 + examples/svelte-chat/components.json | 16 + examples/svelte-chat/package.json | 39 + examples/svelte-chat/src/app.css | 125 ++ examples/svelte-chat/src/app.d.ts | 13 + examples/svelte-chat/src/app.html | 11 + examples/svelte-chat/src/lib/agent.ts | 92 ++ .../svelte-chat/src/lib/assets/favicon.svg | 1 + .../ui/accordion/accordion-content.svelte | 22 + .../ui/accordion/accordion-item.svelte | 17 + .../ui/accordion/accordion-trigger.svelte | 32 + .../components/ui/accordion/accordion.svelte | 16 + .../src/lib/components/ui/accordion/index.ts | 16 + .../ui/alert/alert-description.svelte | 23 + .../components/ui/alert/alert-title.svelte | 20 + .../src/lib/components/ui/alert/alert.svelte | 44 + .../src/lib/components/ui/alert/index.ts | 14 + .../src/lib/components/ui/badge/badge.svelte | 50 + .../src/lib/components/ui/badge/index.ts | 2 + .../lib/components/ui/button/button.svelte | 82 + .../src/lib/components/ui/button/index.ts | 17 + .../lib/components/ui/card/card-action.svelte | 20 + .../components/ui/card/card-content.svelte | 15 + .../ui/card/card-description.svelte | 20 + .../lib/components/ui/card/card-footer.svelte | 20 + .../lib/components/ui/card/card-header.svelte | 23 + .../lib/components/ui/card/card-title.svelte | 20 + .../src/lib/components/ui/card/card.svelte | 23 + .../src/lib/components/ui/card/index.ts | 25 + .../src/lib/components/ui/input/index.ts | 7 + .../src/lib/components/ui/input/input.svelte | 52 + .../src/lib/components/ui/label/index.ts | 7 + .../src/lib/components/ui/label/label.svelte | 20 + .../src/lib/components/ui/progress/index.ts | 7 + .../components/ui/progress/progress.svelte | 27 + .../lib/components/ui/radio-group/index.ts | 10 + .../ui/radio-group/radio-group-item.svelte | 31 + .../ui/radio-group/radio-group.svelte | 19 + .../src/lib/components/ui/select/index.ts | 37 + .../ui/select/select-content.svelte | 45 + .../ui/select/select-group-heading.svelte | 21 + .../components/ui/select/select-group.svelte | 7 + .../components/ui/select/select-item.svelte | 38 + .../components/ui/select/select-label.svelte | 20 + .../components/ui/select/select-portal.svelte | 7 + .../select/select-scroll-down-button.svelte | 20 + .../ui/select/select-scroll-up-button.svelte | 20 + .../ui/select/select-separator.svelte | 18 + .../ui/select/select-trigger.svelte | 29 + .../lib/components/ui/select/select.svelte | 11 + .../src/lib/components/ui/separator/index.ts | 7 + .../components/ui/separator/separator.svelte | 21 + .../src/lib/components/ui/table/index.ts | 28 + .../lib/components/ui/table/table-body.svelte | 20 + .../components/ui/table/table-caption.svelte | 20 + .../lib/components/ui/table/table-cell.svelte | 23 + .../components/ui/table/table-footer.svelte | 20 + .../lib/components/ui/table/table-head.svelte | 23 + .../components/ui/table/table-header.svelte | 20 + .../lib/components/ui/table/table-row.svelte | 23 + .../src/lib/components/ui/table/table.svelte | 22 + .../src/lib/components/ui/tabs/index.ts | 16 + .../components/ui/tabs/tabs-content.svelte | 17 + .../lib/components/ui/tabs/tabs-list.svelte | 20 + .../components/ui/tabs/tabs-trigger.svelte | 20 + .../src/lib/components/ui/tabs/tabs.svelte | 19 + examples/svelte-chat/src/lib/index.ts | 4 + .../src/lib/render/Renderer.svelte | 16 + .../svelte-chat/src/lib/render/catalog.ts | 363 +++++ .../lib/render/components/Accordion.svelte | 22 + .../src/lib/render/components/Alert.svelte | 19 + .../src/lib/render/components/Badge.svelte | 13 + .../src/lib/render/components/BarChart.svelte | 99 ++ .../src/lib/render/components/Button.svelte | 22 + .../src/lib/render/components/Callout.svelte | 57 + .../src/lib/render/components/Card.svelte | 32 + .../src/lib/render/components/Grid.svelte | 31 + .../src/lib/render/components/Heading.svelte | 22 + .../lib/render/components/LineChart.svelte | 109 ++ .../src/lib/render/components/Link.svelte | 19 + .../src/lib/render/components/Metric.svelte | 40 + .../src/lib/render/components/PieChart.svelte | 106 ++ .../src/lib/render/components/Progress.svelte | 13 + .../lib/render/components/RadioGroup.svelte | 45 + .../lib/render/components/SelectInput.svelte | 56 + .../lib/render/components/Separator.svelte | 10 + .../src/lib/render/components/Skeleton.svelte | 14 + .../src/lib/render/components/Stack.svelte | 28 + .../lib/render/components/TabContent.svelte | 19 + .../src/lib/render/components/Table.svelte | 91 ++ .../src/lib/render/components/Tabs.svelte | 29 + .../src/lib/render/components/Text.svelte | 14 + .../lib/render/components/TextInput.svelte | 43 + .../src/lib/render/components/Timeline.svelte | 44 + .../svelte-chat/src/lib/render/registry.ts | 61 + examples/svelte-chat/src/lib/tools/crypto.ts | 165 ++ examples/svelte-chat/src/lib/tools/github.ts | 237 +++ .../svelte-chat/src/lib/tools/hackernews.ts | 67 + examples/svelte-chat/src/lib/tools/search.ts | 36 + examples/svelte-chat/src/lib/tools/weather.ts | 126 ++ examples/svelte-chat/src/lib/utils.ts | 18 + .../svelte-chat/src/routes/+layout.svelte | 13 + examples/svelte-chat/src/routes/+page.svelte | 381 +++++ .../src/routes/api/generate/+server.ts | 35 + examples/svelte-chat/static/robots.txt | 3 + examples/svelte-chat/svelte.config.js | 10 + examples/svelte-chat/tsconfig.json | 20 + examples/svelte-chat/vite.config.ts | 10 + examples/svelte/index.html | 12 + examples/svelte/package.json | 24 + examples/svelte/src/App.svelte | 11 + examples/svelte/src/DemoRenderer.svelte | 46 + examples/svelte/src/app.css | 13 + examples/svelte/src/lib/catalog.ts | 90 ++ .../svelte/src/lib/components/Badge.svelte | 25 + .../svelte/src/lib/components/Button.svelte | 45 + .../svelte/src/lib/components/Card.svelte | 48 + .../svelte/src/lib/components/Input.svelte | 29 + .../svelte/src/lib/components/ListItem.svelte | 50 + .../svelte/src/lib/components/Stack.svelte | 36 + .../svelte/src/lib/components/Text.svelte | 35 + examples/svelte/src/lib/registry.ts | 22 + examples/svelte/src/lib/spec.ts | 130 ++ examples/svelte/src/main.ts | 9 + examples/svelte/src/vite-env.d.ts | 1 + examples/svelte/svelte.config.js | 1 + examples/svelte/tsconfig.json | 17 + examples/svelte/vite.config.ts | 6 + examples/vite-renderers/package.json | 6 +- examples/vite-renderers/src/main.ts | 8 +- .../vite-renderers/src/react/registry.tsx | 17 +- .../vite-renderers/src/shared/catalog-def.ts | 6 +- .../vite-renderers/src/shared/handlers.ts | 6 + examples/vite-renderers/src/shared/styles.css | 15 + examples/vite-renderers/src/spec.ts | 4 +- examples/vite-renderers/src/svelte/App.svelte | 22 + .../src/svelte/DemoRenderer.svelte | 28 + examples/vite-renderers/src/svelte/catalog.ts | 4 + .../src/svelte/components/Badge.svelte | 25 + .../src/svelte/components/Button.svelte | 45 + .../src/svelte/components/Card.svelte | 48 + .../src/svelte/components/Input.svelte | 29 + .../src/svelte/components/ListItem.svelte | 50 + .../svelte/components/RendererBadge.svelte | 17 + .../src/svelte/components/RendererTabs.svelte | 47 + .../src/svelte/components/Stack.svelte | 36 + .../src/svelte/components/Text.svelte | 35 + examples/vite-renderers/src/svelte/mount.ts | 19 + .../vite-renderers/src/svelte/registry.ts | 28 + examples/vite-renderers/src/vue/registry.ts | 19 +- examples/vite-renderers/svelte.config.js | 1 + examples/vite-renderers/tsconfig.json | 2 +- examples/vite-renderers/vite.config.ts | 3 +- examples/vue/package.json | 2 +- examples/vue/src/DemoRenderer.vue | 1 + package.json | 2 +- packages/svelte/package.json | 2 +- .../src/contexts/RepeatScopeProvider.svelte | 2 +- .../svelte/src/contexts/StateProvider.svelte | 7 +- packages/svelte/svelte.config.js | 5 +- pnpm-lock.yaml | 1411 ++++++++++++----- vitest.config.mts | 2 +- 165 files changed, 6404 insertions(+), 421 deletions(-) create mode 100644 examples/svelte-chat/.gitignore create mode 100644 examples/svelte-chat/.npmrc create mode 100644 examples/svelte-chat/README.md create mode 100644 examples/svelte-chat/components.json create mode 100644 examples/svelte-chat/package.json create mode 100644 examples/svelte-chat/src/app.css create mode 100644 examples/svelte-chat/src/app.d.ts create mode 100644 examples/svelte-chat/src/app.html create mode 100644 examples/svelte-chat/src/lib/agent.ts create mode 100644 examples/svelte-chat/src/lib/assets/favicon.svg create mode 100644 examples/svelte-chat/src/lib/components/ui/accordion/accordion-content.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/accordion/accordion-item.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/accordion/accordion-trigger.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/accordion/accordion.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/accordion/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/alert/alert-description.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/alert/alert-title.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/alert/alert.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/alert/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/badge/badge.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/badge/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/button/button.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/button/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/card/card-action.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/card/card-content.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/card/card-description.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/card/card-footer.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/card/card-header.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/card/card-title.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/card/card.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/card/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/input/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/input/input.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/label/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/label/label.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/progress/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/progress/progress.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/radio-group/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/radio-group/radio-group-item.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/radio-group/radio-group.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/select/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/select/select-content.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/select/select-group-heading.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/select/select-group.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/select/select-item.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/select/select-label.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/select/select-portal.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/select/select-scroll-down-button.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/select/select-scroll-up-button.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/select/select-separator.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/select/select-trigger.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/select/select.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/separator/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/separator/separator.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/table/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/table/table-body.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/table/table-caption.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/table/table-cell.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/table/table-footer.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/table/table-head.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/table/table-header.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/table/table-row.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/table/table.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/tabs/index.ts create mode 100644 examples/svelte-chat/src/lib/components/ui/tabs/tabs-content.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/tabs/tabs-list.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/tabs/tabs-trigger.svelte create mode 100644 examples/svelte-chat/src/lib/components/ui/tabs/tabs.svelte create mode 100644 examples/svelte-chat/src/lib/index.ts create mode 100644 examples/svelte-chat/src/lib/render/Renderer.svelte create mode 100644 examples/svelte-chat/src/lib/render/catalog.ts create mode 100644 examples/svelte-chat/src/lib/render/components/Accordion.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Alert.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Badge.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/BarChart.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Button.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Callout.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Card.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Grid.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Heading.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/LineChart.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Link.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Metric.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/PieChart.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Progress.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/RadioGroup.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/SelectInput.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Separator.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Skeleton.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Stack.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/TabContent.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Table.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Tabs.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Text.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/TextInput.svelte create mode 100644 examples/svelte-chat/src/lib/render/components/Timeline.svelte create mode 100644 examples/svelte-chat/src/lib/render/registry.ts create mode 100644 examples/svelte-chat/src/lib/tools/crypto.ts create mode 100644 examples/svelte-chat/src/lib/tools/github.ts create mode 100644 examples/svelte-chat/src/lib/tools/hackernews.ts create mode 100644 examples/svelte-chat/src/lib/tools/search.ts create mode 100644 examples/svelte-chat/src/lib/tools/weather.ts create mode 100644 examples/svelte-chat/src/lib/utils.ts create mode 100644 examples/svelte-chat/src/routes/+layout.svelte create mode 100644 examples/svelte-chat/src/routes/+page.svelte create mode 100644 examples/svelte-chat/src/routes/api/generate/+server.ts create mode 100644 examples/svelte-chat/static/robots.txt create mode 100644 examples/svelte-chat/svelte.config.js create mode 100644 examples/svelte-chat/tsconfig.json create mode 100644 examples/svelte-chat/vite.config.ts create mode 100644 examples/svelte/index.html create mode 100644 examples/svelte/package.json create mode 100644 examples/svelte/src/App.svelte create mode 100644 examples/svelte/src/DemoRenderer.svelte create mode 100644 examples/svelte/src/app.css create mode 100644 examples/svelte/src/lib/catalog.ts create mode 100644 examples/svelte/src/lib/components/Badge.svelte create mode 100644 examples/svelte/src/lib/components/Button.svelte create mode 100644 examples/svelte/src/lib/components/Card.svelte create mode 100644 examples/svelte/src/lib/components/Input.svelte create mode 100644 examples/svelte/src/lib/components/ListItem.svelte create mode 100644 examples/svelte/src/lib/components/Stack.svelte create mode 100644 examples/svelte/src/lib/components/Text.svelte create mode 100644 examples/svelte/src/lib/registry.ts create mode 100644 examples/svelte/src/lib/spec.ts create mode 100644 examples/svelte/src/main.ts create mode 100644 examples/svelte/src/vite-env.d.ts create mode 100644 examples/svelte/svelte.config.js create mode 100644 examples/svelte/tsconfig.json create mode 100644 examples/svelte/vite.config.ts create mode 100644 examples/vite-renderers/src/svelte/App.svelte create mode 100644 examples/vite-renderers/src/svelte/DemoRenderer.svelte create mode 100644 examples/vite-renderers/src/svelte/catalog.ts create mode 100644 examples/vite-renderers/src/svelte/components/Badge.svelte create mode 100644 examples/vite-renderers/src/svelte/components/Button.svelte create mode 100644 examples/vite-renderers/src/svelte/components/Card.svelte create mode 100644 examples/vite-renderers/src/svelte/components/Input.svelte create mode 100644 examples/vite-renderers/src/svelte/components/ListItem.svelte create mode 100644 examples/vite-renderers/src/svelte/components/RendererBadge.svelte create mode 100644 examples/vite-renderers/src/svelte/components/RendererTabs.svelte create mode 100644 examples/vite-renderers/src/svelte/components/Stack.svelte create mode 100644 examples/vite-renderers/src/svelte/components/Text.svelte create mode 100644 examples/vite-renderers/src/svelte/mount.ts create mode 100644 examples/vite-renderers/src/svelte/registry.ts create mode 100644 examples/vite-renderers/svelte.config.js diff --git a/README.md b/README.md index b0a37562..0babd2cc 100644 --- a/README.md +++ b/README.md @@ -394,8 +394,9 @@ pnpm dev - http://dashboard-demo.json-render.localhost:1355 - Example Dashboard - 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): run `pnpm dev` in `examples/vite-renderers` +- Vite Renderers (React + Vue + Svelte): run `pnpm dev` in `examples/vite-renderers` - React Native example: run `npx expo start` in `examples/react-native` ## How It Works diff --git a/examples/svelte-chat/.gitignore b/examples/svelte-chat/.gitignore new file mode 100644 index 00000000..3b462cb0 --- /dev/null +++ b/examples/svelte-chat/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/examples/svelte-chat/.npmrc b/examples/svelte-chat/.npmrc new file mode 100644 index 00000000..b6f27f13 --- /dev/null +++ b/examples/svelte-chat/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/examples/svelte-chat/README.md b/examples/svelte-chat/README.md new file mode 100644 index 00000000..3d2e6bf2 --- /dev/null +++ b/examples/svelte-chat/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv create --template minimal --types ts --no-install svelte-chat +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/examples/svelte-chat/components.json b/examples/svelte-chat/components.json new file mode 100644 index 00000000..f258682d --- /dev/null +++ b/examples/svelte-chat/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/app.css", + "baseColor": "zinc" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/examples/svelte-chat/package.json b/examples/svelte-chat/package.json new file mode 100644 index 00000000..f3d01470 --- /dev/null +++ b/examples/svelte-chat/package.json @@ -0,0 +1,39 @@ +{ + "name": "svelte-chat", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check-types": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@internationalized/date": "^3.10.0", + "@lucide/svelte": "^0.561.0", + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.0.0", + "bits-ui": "^2.14.4", + "svelte": "^5.49.2", + "svelte-check": "^4.3.6", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.0.0", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "@ai-sdk/gateway": "^3.0.46", + "@ai-sdk/svelte": "^4.0.96", + "@json-render/core": "workspace:*", + "@json-render/svelte": "workspace:*", + "ai": "^6.0.86", + "clsx": "^2.1.1", + "lucide-svelte": "^0.500.0", + "tailwind-merge": "^3.2.0", + "zod": "4.3.5" + } +} diff --git a/examples/svelte-chat/src/app.css b/examples/svelte-chat/src/app.css new file mode 100644 index 00000000..b95a3cda --- /dev/null +++ b/examples/svelte-chat/src/app.css @@ -0,0 +1,125 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +@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); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --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-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +button { + cursor: pointer; +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.animate-shimmer { + background: linear-gradient( + 90deg, + currentColor 25%, + hsl(0 0% 64%) 50%, + currentColor 75% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer 2s ease-in-out infinite; +} diff --git a/examples/svelte-chat/src/app.d.ts b/examples/svelte-chat/src/app.d.ts new file mode 100644 index 00000000..520c4217 --- /dev/null +++ b/examples/svelte-chat/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/examples/svelte-chat/src/app.html b/examples/svelte-chat/src/app.html new file mode 100644 index 00000000..f273cc58 --- /dev/null +++ b/examples/svelte-chat/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +

%sveltekit.body%
+ + diff --git a/examples/svelte-chat/src/lib/agent.ts b/examples/svelte-chat/src/lib/agent.ts new file mode 100644 index 00000000..42331bea --- /dev/null +++ b/examples/svelte-chat/src/lib/agent.ts @@ -0,0 +1,92 @@ +import { ToolLoopAgent, stepCountIs } from "ai"; +import { createGateway } from "@ai-sdk/gateway"; +import { explorerCatalog } from "./render/catalog"; +import { getWeather } from "./tools/weather"; +import { getGitHubRepo, getGitHubPullRequests } from "./tools/github"; +import { getCryptoPrice, getCryptoPriceHistory } from "./tools/crypto"; +import { getHackerNewsTop } from "./tools/hackernews"; +import { webSearch } from "./tools/search"; +import { env } from "$env/dynamic/private"; + +const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; + +const AGENT_INSTRUCTIONS = `You are a knowledgeable assistant that helps users explore data and learn about any topic. You look up real-time information, build visual dashboards, and create rich educational content. + +WORKFLOW: +1. Call the appropriate tools to gather relevant data. Use webSearch for general topics not covered by specialized tools. +2. Respond with a brief, conversational summary of what you found. +3. Then output the JSONL UI spec wrapped in a \`\`\`spec fence to render a rich visual experience. + +RULES: +- Always call tools FIRST to get real data. Never make up data. +- Embed the fetched data directly in /state paths so components can reference it. +- Use Card components to group related information. +- NEVER nest a Card inside another Card. If you need sub-sections inside a Card, use Stack, Separator, Heading, or Accordion instead. +- Use Grid for multi-column layouts. +- Use Metric for key numeric values (temperature, stars, price, etc.). +- Use Table for lists of items (stories, forecasts, languages, etc.). +- Use BarChart or LineChart for numeric trends and time-series data. +- Use PieChart for compositional/proportional data (market share, breakdowns, distributions). +- Use Tabs when showing multiple categories of data side by side. +- Use Badge for status indicators. +- Use Callout for key facts, tips, warnings, or important takeaways. +- Use Accordion to organize detailed sections the user can expand for deeper reading. +- Use Timeline for historical events, processes, step-by-step explanations, or milestones. +- When teaching about a topic, combine multiple component types to create a rich, engaging experience. + +DATA BINDING: +- The state model is the single source of truth. Put fetched data in /state, then reference it with { "$state": "/json/pointer" } in any prop. +- $state works on ANY prop at ANY nesting level. The renderer resolves expressions before components receive props. +- Scalar binding: "title": { "$state": "/quiz/title" } +- Array binding: "items": { "$state": "/quiz/questions" } (for Accordion, Timeline, etc.) +- For Table, BarChart, LineChart, and PieChart, use { "$state": "/path" } on the data prop to bind read-only data from state. +- Always emit /state patches BEFORE the elements that reference them, so data is available when the UI renders. +- Always use the { "$state": "/foo" } object syntax for data binding. + +INTERACTIVITY: +- You can use visible, repeat, on.press, and $cond/$then/$else freely. +- visible: Conditionally show/hide elements based on state. e.g. "visible": { "$state": "/q1/answer", "eq": "a" } +- repeat: Iterate over state arrays. e.g. "repeat": { "statePath": "/items" } +- on.press: Trigger actions on button clicks. e.g. "on": { "press": { "action": "setState", "params": { "statePath": "/submitted", "value": true } } } +- $cond/$then/$else: Conditional prop values. e.g. { "$cond": { "$state": "/correct" }, "$then": "Correct!", "$else": "Try again" } + +BUILT-IN ACTIONS (use with on.press): +- setState: Set a value at a state path. params: { statePath: "/foo", value: "bar" } +- pushState: Append to an array. params: { statePath: "/items", value: { ... } } +- removeState: Remove by index. params: { statePath: "/items", index: 0 } + +INPUT COMPONENTS: +- RadioGroup: Renders radio buttons. Writes selected value to statePath automatically. +- SelectInput: Dropdown select. Writes selected value to statePath automatically. +- TextInput: Text input field. Writes entered value to statePath automatically. +- Button: Clickable button. Use on.press to trigger actions. + +${explorerCatalog.prompt({ + mode: "chat", + customRules: [ + "NEVER use viewport height classes (min-h-screen, h-screen) — the UI renders inside a fixed-size container.", + "Prefer Grid with columns='2' or columns='3' for side-by-side layouts.", + "Use Metric components for key numbers instead of plain Text.", + "Put chart data arrays in /state and reference them with { $state: '/path' } on the data prop.", + "Keep the UI clean and information-dense — no excessive padding or empty space.", + "For educational prompts ('teach me about', 'explain', 'what is'), use a mix of Callout, Accordion, Timeline, and charts to make the content visually rich.", + ], +})}`; + +export const gateway = createGateway({ apiKey: env.AI_GATEWAY_API_KEY }); + +export const agent = new ToolLoopAgent({ + model: gateway(env.AI_GATEWAY_MODEL || DEFAULT_MODEL), + instructions: AGENT_INSTRUCTIONS, + tools: { + getWeather, + getGitHubRepo, + getGitHubPullRequests, + getCryptoPrice, + getCryptoPriceHistory, + getHackerNewsTop, + webSearch, + }, + stopWhen: stepCountIs(5), + temperature: 0.7, +}); diff --git a/examples/svelte-chat/src/lib/assets/favicon.svg b/examples/svelte-chat/src/lib/assets/favicon.svg new file mode 100644 index 00000000..cc5dc66a --- /dev/null +++ b/examples/svelte-chat/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/examples/svelte-chat/src/lib/components/ui/accordion/accordion-content.svelte b/examples/svelte-chat/src/lib/components/ui/accordion/accordion-content.svelte new file mode 100644 index 00000000..559db3d5 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/accordion/accordion-content.svelte @@ -0,0 +1,22 @@ + + + +
+ {@render children?.()} +
+
diff --git a/examples/svelte-chat/src/lib/components/ui/accordion/accordion-item.svelte b/examples/svelte-chat/src/lib/components/ui/accordion/accordion-item.svelte new file mode 100644 index 00000000..780545c6 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/accordion/accordion-item.svelte @@ -0,0 +1,17 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/accordion/accordion-trigger.svelte b/examples/svelte-chat/src/lib/components/ui/accordion/accordion-trigger.svelte new file mode 100644 index 00000000..c46c2468 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/accordion/accordion-trigger.svelte @@ -0,0 +1,32 @@ + + + + svg]:rotate-180", + className + )} + {...restProps} + > + {@render children?.()} + + + diff --git a/examples/svelte-chat/src/lib/components/ui/accordion/accordion.svelte b/examples/svelte-chat/src/lib/components/ui/accordion/accordion.svelte new file mode 100644 index 00000000..117ee37f --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/accordion/accordion.svelte @@ -0,0 +1,16 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/accordion/index.ts b/examples/svelte-chat/src/lib/components/ui/accordion/index.ts new file mode 100644 index 00000000..ef0dab75 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/accordion/index.ts @@ -0,0 +1,16 @@ +import Root from "./accordion.svelte"; +import Content from "./accordion-content.svelte"; +import Item from "./accordion-item.svelte"; +import Trigger from "./accordion-trigger.svelte"; + +export { + Root, + Content, + Item, + Trigger, + // + Root as Accordion, + Content as AccordionContent, + Item as AccordionItem, + Trigger as AccordionTrigger, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/alert/alert-description.svelte b/examples/svelte-chat/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 00000000..8b56aed2 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/examples/svelte-chat/src/lib/components/ui/alert/alert-title.svelte b/examples/svelte-chat/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 00000000..77e45ad5 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/examples/svelte-chat/src/lib/components/ui/alert/alert.svelte b/examples/svelte-chat/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 00000000..2b2eff9a --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,44 @@ + + + + + diff --git a/examples/svelte-chat/src/lib/components/ui/alert/index.ts b/examples/svelte-chat/src/lib/components/ui/alert/index.ts new file mode 100644 index 00000000..e47ba7d3 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/alert/index.ts @@ -0,0 +1,14 @@ +import Root from "./alert.svelte"; +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; +export { alertVariants, type AlertVariant } from "./alert.svelte"; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/badge/badge.svelte b/examples/svelte-chat/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 00000000..e3164ba7 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,50 @@ + + + + + + {@render children?.()} + diff --git a/examples/svelte-chat/src/lib/components/ui/badge/index.ts b/examples/svelte-chat/src/lib/components/ui/badge/index.ts new file mode 100644 index 00000000..64e0aa9b --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from "./badge.svelte"; +export { badgeVariants, type BadgeVariant } from "./badge.svelte"; diff --git a/examples/svelte-chat/src/lib/components/ui/button/button.svelte b/examples/svelte-chat/src/lib/components/ui/button/button.svelte new file mode 100644 index 00000000..a8296aed --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/examples/svelte-chat/src/lib/components/ui/button/index.ts b/examples/svelte-chat/src/lib/components/ui/button/index.ts new file mode 100644 index 00000000..872d97cb --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/card/card-action.svelte b/examples/svelte-chat/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 00000000..cc36c566 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/examples/svelte-chat/src/lib/components/ui/card/card-content.svelte b/examples/svelte-chat/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 00000000..bc90b837 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/examples/svelte-chat/src/lib/components/ui/card/card-description.svelte b/examples/svelte-chat/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 00000000..9b20ac70 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

+ {@render children?.()} +

diff --git a/examples/svelte-chat/src/lib/components/ui/card/card-footer.svelte b/examples/svelte-chat/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 00000000..2d4d0f24 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/examples/svelte-chat/src/lib/components/ui/card/card-header.svelte b/examples/svelte-chat/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 00000000..25017884 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/examples/svelte-chat/src/lib/components/ui/card/card-title.svelte b/examples/svelte-chat/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 00000000..74472311 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/examples/svelte-chat/src/lib/components/ui/card/card.svelte b/examples/svelte-chat/src/lib/components/ui/card/card.svelte new file mode 100644 index 00000000..99448cc9 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/card/card.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/examples/svelte-chat/src/lib/components/ui/card/index.ts b/examples/svelte-chat/src/lib/components/ui/card/index.ts new file mode 100644 index 00000000..406a5ceb --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Action from "./card-action.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/input/index.ts b/examples/svelte-chat/src/lib/components/ui/input/index.ts new file mode 100644 index 00000000..ceb4b164 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/input/input.svelte b/examples/svelte-chat/src/lib/components/ui/input/input.svelte new file mode 100644 index 00000000..ff1a4c87 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/input/input.svelte @@ -0,0 +1,52 @@ + + +{#if type === "file"} + +{:else} + +{/if} diff --git a/examples/svelte-chat/src/lib/components/ui/label/index.ts b/examples/svelte-chat/src/lib/components/ui/label/index.ts new file mode 100644 index 00000000..b0b23ce0 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/label/label.svelte b/examples/svelte-chat/src/lib/components/ui/label/label.svelte new file mode 100644 index 00000000..d71afbca --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/progress/index.ts b/examples/svelte-chat/src/lib/components/ui/progress/index.ts new file mode 100644 index 00000000..1e415fc3 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/progress/index.ts @@ -0,0 +1,7 @@ +import Root from "./progress.svelte"; + +export { + Root, + // + Root as Progress, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/progress/progress.svelte b/examples/svelte-chat/src/lib/components/ui/progress/progress.svelte new file mode 100644 index 00000000..68330136 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/progress/progress.svelte @@ -0,0 +1,27 @@ + + + +
+
diff --git a/examples/svelte-chat/src/lib/components/ui/radio-group/index.ts b/examples/svelte-chat/src/lib/components/ui/radio-group/index.ts new file mode 100644 index 00000000..b6089461 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/radio-group/index.ts @@ -0,0 +1,10 @@ +import Root from "./radio-group.svelte"; +import Item from "./radio-group-item.svelte"; + +export { + Root, + Item, + // + Root as RadioGroup, + Item as RadioGroupItem, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/radio-group/radio-group-item.svelte b/examples/svelte-chat/src/lib/components/ui/radio-group/radio-group-item.svelte new file mode 100644 index 00000000..f0813db3 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/radio-group/radio-group-item.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children({ checked })} +
+ {#if checked} + + {/if} +
+ {/snippet} +
diff --git a/examples/svelte-chat/src/lib/components/ui/radio-group/radio-group.svelte b/examples/svelte-chat/src/lib/components/ui/radio-group/radio-group.svelte new file mode 100644 index 00000000..da2912b0 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/radio-group/radio-group.svelte @@ -0,0 +1,19 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/select/index.ts b/examples/svelte-chat/src/lib/components/ui/select/index.ts new file mode 100644 index 00000000..222d568a --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import Root from "./select.svelte"; +import Group from "./select-group.svelte"; +import Label from "./select-label.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import GroupHeading from "./select-group-heading.svelte"; +import Portal from "./select-portal.svelte"; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + Portal, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, + Portal as SelectPortal, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/select/select-content.svelte b/examples/svelte-chat/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 00000000..4b9ca438 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,45 @@ + + + + + + + {@render children?.()} + + + + diff --git a/examples/svelte-chat/src/lib/components/ui/select/select-group-heading.svelte b/examples/svelte-chat/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 00000000..1fab5f00 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/examples/svelte-chat/src/lib/components/ui/select/select-group.svelte b/examples/svelte-chat/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 00000000..a1f43bf3 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/select/select-item.svelte b/examples/svelte-chat/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 00000000..b85eef69 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/examples/svelte-chat/src/lib/components/ui/select/select-label.svelte b/examples/svelte-chat/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 00000000..46960259 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/examples/svelte-chat/src/lib/components/ui/select/select-portal.svelte b/examples/svelte-chat/src/lib/components/ui/select/select-portal.svelte new file mode 100644 index 00000000..424bcddc --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/select-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/select/select-scroll-down-button.svelte b/examples/svelte-chat/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 00000000..36292058 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/examples/svelte-chat/src/lib/components/ui/select/select-scroll-up-button.svelte b/examples/svelte-chat/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 00000000..1aa2300c --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/examples/svelte-chat/src/lib/components/ui/select/select-separator.svelte b/examples/svelte-chat/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 00000000..0eac3ebc --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/select/select-trigger.svelte b/examples/svelte-chat/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 00000000..dbb81dfa --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/examples/svelte-chat/src/lib/components/ui/select/select.svelte b/examples/svelte-chat/src/lib/components/ui/select/select.svelte new file mode 100644 index 00000000..05eb6634 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/select/select.svelte @@ -0,0 +1,11 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/separator/index.ts b/examples/svelte-chat/src/lib/components/ui/separator/index.ts new file mode 100644 index 00000000..d66644e4 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/separator/separator.svelte b/examples/svelte-chat/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 00000000..f40999fa --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,21 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/table/index.ts b/examples/svelte-chat/src/lib/components/ui/table/index.ts new file mode 100644 index 00000000..3fe1e39d --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Root from "./table.svelte"; +import Body from "./table-body.svelte"; +import Caption from "./table-caption.svelte"; +import Cell from "./table-cell.svelte"; +import Footer from "./table-footer.svelte"; +import Head from "./table-head.svelte"; +import Header from "./table-header.svelte"; +import Row from "./table-row.svelte"; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/table/table-body.svelte b/examples/svelte-chat/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 00000000..29e96875 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/examples/svelte-chat/src/lib/components/ui/table/table-caption.svelte b/examples/svelte-chat/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 00000000..4696cff5 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/examples/svelte-chat/src/lib/components/ui/table/table-cell.svelte b/examples/svelte-chat/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 00000000..2c0c26a0 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/examples/svelte-chat/src/lib/components/ui/table/table-footer.svelte b/examples/svelte-chat/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 00000000..b9b14ebf --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ + + +tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} + diff --git a/examples/svelte-chat/src/lib/components/ui/table/table-head.svelte b/examples/svelte-chat/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 00000000..b67a6f9b --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/examples/svelte-chat/src/lib/components/ui/table/table-header.svelte b/examples/svelte-chat/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 00000000..f47d2597 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/examples/svelte-chat/src/lib/components/ui/table/table-row.svelte b/examples/svelte-chat/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 00000000..0df769e0 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,23 @@ + + +svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", + className + )} + {...restProps} +> + {@render children?.()} + diff --git a/examples/svelte-chat/src/lib/components/ui/table/table.svelte b/examples/svelte-chat/src/lib/components/ui/table/table.svelte new file mode 100644 index 00000000..a3349563 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/table/table.svelte @@ -0,0 +1,22 @@ + + +
+ + {@render children?.()} +
+
diff --git a/examples/svelte-chat/src/lib/components/ui/tabs/index.ts b/examples/svelte-chat/src/lib/components/ui/tabs/index.ts new file mode 100644 index 00000000..4c728b6e --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,16 @@ +import Root from "./tabs.svelte"; +import Content from "./tabs-content.svelte"; +import List from "./tabs-list.svelte"; +import Trigger from "./tabs-trigger.svelte"; + +export { + Root, + Content, + List, + Trigger, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/examples/svelte-chat/src/lib/components/ui/tabs/tabs-content.svelte b/examples/svelte-chat/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 00000000..340d65cf --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/tabs/tabs-list.svelte b/examples/svelte-chat/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 00000000..08932b60 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,20 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/tabs/tabs-trigger.svelte b/examples/svelte-chat/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 00000000..e623b366 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,20 @@ + + + diff --git a/examples/svelte-chat/src/lib/components/ui/tabs/tabs.svelte b/examples/svelte-chat/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 00000000..ef6cada5 --- /dev/null +++ b/examples/svelte-chat/src/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/examples/svelte-chat/src/lib/index.ts b/examples/svelte-chat/src/lib/index.ts new file mode 100644 index 00000000..71fe4cc3 --- /dev/null +++ b/examples/svelte-chat/src/lib/index.ts @@ -0,0 +1,4 @@ +// place files you want to import through the `$lib` alias in this folder. +export { explorerCatalog } from "./render/catalog"; +export { registry } from "./render/registry"; +export { agent } from "./agent"; diff --git a/examples/svelte-chat/src/lib/render/Renderer.svelte b/examples/svelte-chat/src/lib/render/Renderer.svelte new file mode 100644 index 00000000..fe03f820 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/Renderer.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/examples/svelte-chat/src/lib/render/catalog.ts b/examples/svelte-chat/src/lib/render/catalog.ts new file mode 100644 index 00000000..bb0c5379 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/catalog.ts @@ -0,0 +1,363 @@ +import { schema } from "@json-render/svelte/schema"; +import { z } from "zod"; + +/** + * json-render + AI SDK Example Catalog (Svelte) + * + * Components for rendering data dashboards generated by the ToolLoopAgent. + * Data flows in through tools (weather, GitHub, crypto, HN), not user actions. + */ +export const explorerCatalog = schema.createCatalog({ + components: { + // Layout + Stack: { + props: z.object({ + direction: z.enum(["horizontal", "vertical"]).nullable(), + gap: z.enum(["sm", "md", "lg"]).nullable(), + wrap: z.boolean().nullable(), + }), + slots: ["default"], + description: "Flex layout container", + example: { direction: "vertical", gap: "md", wrap: null }, + }, + + Card: { + props: z.object({ + title: z.string().nullable(), + description: z.string().nullable(), + }), + slots: ["default"], + description: "Card container with optional title and description", + example: { title: "Weather", description: "Current conditions" }, + }, + + Grid: { + props: z.object({ + columns: z.enum(["1", "2", "3", "4"]).nullable(), + gap: z.enum(["sm", "md", "lg"]).nullable(), + }), + slots: ["default"], + description: "Responsive grid layout container", + example: { columns: "3", gap: "md" }, + }, + + // Typography + Heading: { + props: z.object({ + text: z.string(), + level: z.enum(["h1", "h2", "h3", "h4"]).nullable(), + }), + description: "Section heading", + example: { text: "Data Explorer", level: "h1" }, + }, + + Text: { + props: z.object({ + content: z.string(), + muted: z.boolean().nullable(), + }), + description: "Text content", + example: { content: "Here is your data overview." }, + }, + + // Data display + Badge: { + props: z.object({ + text: z.string(), + variant: z + .enum(["default", "secondary", "destructive", "outline"]) + .nullable(), + }), + description: "Status badge", + example: { text: "Live", variant: "default" }, + }, + + Alert: { + props: z.object({ + variant: z.enum(["default", "destructive"]).nullable(), + title: z.string(), + description: z.string().nullable(), + }), + description: "Alert or info message", + }, + + Separator: { + props: z.object({}), + description: "Visual divider", + }, + + Metric: { + props: z.object({ + label: z.string(), + value: z.string(), + detail: z.string().nullable(), + trend: z.enum(["up", "down", "neutral"]).nullable(), + }), + description: + "Single metric display with label, value, and optional trend indicator", + example: { + label: "Temperature", + value: "72F", + detail: "Feels like 68F", + trend: "up", + }, + }, + + Table: { + props: z.object({ + data: z.array(z.record(z.string(), z.unknown())), + columns: z.array( + z.object({ + key: z.string(), + label: z.string(), + }), + ), + emptyMessage: z.string().nullable(), + }), + description: + 'Data table. Use { "$state": "/path" } to bind read-only data from state.', + example: { + data: { $state: "/stories" }, + columns: [ + { key: "title", label: "Title" }, + { key: "score", label: "Score" }, + ], + }, + }, + + Link: { + props: z.object({ + text: z.string(), + href: z.string(), + }), + description: "External link that opens in a new tab", + example: { text: "View on GitHub", href: "https://github.com" }, + }, + + // Charts + BarChart: { + props: z.object({ + title: z.string().nullable(), + data: z.array(z.record(z.string(), z.unknown())), + xKey: z.string(), + yKey: z.string(), + aggregate: z.enum(["sum", "count", "avg"]).nullable(), + color: z.string().nullable(), + height: z.number().nullable(), + }), + description: + 'Bar chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the category field, yKey is the numeric value field.', + }, + + LineChart: { + props: z.object({ + title: z.string().nullable(), + data: z.array(z.record(z.string(), z.unknown())), + xKey: z.string(), + yKey: z.string(), + aggregate: z.enum(["sum", "count", "avg"]).nullable(), + color: z.string().nullable(), + height: z.number().nullable(), + }), + description: + 'Line chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the x-axis field, yKey is the numeric value field.', + }, + + // Interactive + Tabs: { + props: z.object({ + defaultValue: z.string().nullable(), + tabs: z.array( + z.object({ + value: z.string(), + label: z.string(), + }), + ), + }), + slots: ["default"], + description: "Tabbed content container", + }, + + TabContent: { + props: z.object({ + value: z.string(), + }), + slots: ["default"], + description: "Content for a specific tab", + }, + + Progress: { + props: z.object({ + value: z.number(), + max: z.number().nullable(), + }), + description: "Progress bar", + }, + + Skeleton: { + props: z.object({ + width: z.string().nullable(), + height: z.string().nullable(), + }), + description: "Loading placeholder", + }, + + // Educational / Rich content + Callout: { + props: z.object({ + type: z.enum(["info", "tip", "warning", "important"]).nullable(), + title: z.string().nullable(), + content: z.string(), + }), + description: + "Highlighted callout box for tips, warnings, notes, or key information", + example: { + type: "tip", + title: "Did you know?", + content: "The sun is about 93 million miles from Earth.", + }, + }, + + Accordion: { + props: z.object({ + items: z.array( + z.object({ + title: z.string(), + content: z.string(), + }), + ), + type: z.enum(["single", "multiple"]).nullable(), + }), + description: + "Collapsible accordion sections for organizing detailed content", + example: { + items: [{ title: "Overview", content: "A brief introduction." }], + type: "multiple", + }, + }, + + Timeline: { + props: z.object({ + items: z.array( + z.object({ + title: z.string(), + description: z.string().nullable(), + date: z.string().nullable(), + status: z.enum(["completed", "current", "upcoming"]).nullable(), + }), + ), + }), + description: + "Vertical timeline showing ordered events, steps, or historical milestones", + example: { + items: [ + { + title: "Discovery", + description: "Initial breakthrough", + date: "1905", + status: "completed", + }, + ], + }, + }, + + PieChart: { + props: z.object({ + title: z.string().nullable(), + data: z.array(z.record(z.string(), z.unknown())), + nameKey: z.string(), + valueKey: z.string(), + height: z.number().nullable(), + }), + description: + 'Pie/donut chart for proportional data. Use { "$state": "/path" } to bind read-only data. nameKey is the label field, valueKey is the numeric value field.', + }, + + // Interactive / Input + RadioGroup: { + props: z.object({ + label: z.string().nullable(), + value: z.string().nullable(), + options: z.array( + z.object({ + value: z.string(), + label: z.string(), + }), + ), + }), + description: + 'Radio button group for single selection. Use { "$bindState": "/path" } for two-way binding.', + example: { + label: "Choose one", + value: { $bindState: "/answer" }, + options: [ + { value: "a", label: "Option A" }, + { value: "b", label: "Option B" }, + ], + }, + }, + + SelectInput: { + props: z.object({ + label: z.string().nullable(), + value: z.string().nullable(), + placeholder: z.string().nullable(), + options: z.array( + z.object({ + value: z.string(), + label: z.string(), + }), + ), + }), + description: + 'Dropdown select input. Use { "$bindState": "/path" } for two-way binding.', + example: { + label: "Country", + value: { $bindState: "/selectedCountry" }, + placeholder: "Select a country", + options: [ + { value: "us", label: "United States" }, + { value: "uk", label: "United Kingdom" }, + ], + }, + }, + + TextInput: { + props: z.object({ + label: z.string().nullable(), + value: z.string().nullable(), + placeholder: z.string().nullable(), + type: z.enum(["text", "email", "number", "password", "url"]).nullable(), + }), + description: + 'Text input field. Use { "$bindState": "/path" } for two-way binding.', + example: { + label: "Your name", + value: { $bindState: "/userName" }, + placeholder: "Enter your name", + type: "text", + }, + }, + + Button: { + props: z.object({ + label: z.string(), + variant: z + .enum(["default", "secondary", "destructive", "outline", "ghost"]) + .nullable(), + size: z.enum(["default", "sm", "lg"]).nullable(), + disabled: z.boolean().nullable(), + }), + description: + "Clickable button. Use with on.press to trigger actions like setState.", + example: { + label: "Submit", + variant: "default", + size: "default", + disabled: null, + }, + }, + }, + + actions: {}, +}); diff --git a/examples/svelte-chat/src/lib/render/components/Accordion.svelte b/examples/svelte-chat/src/lib/render/components/Accordion.svelte new file mode 100644 index 00000000..9bcd6790 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Accordion.svelte @@ -0,0 +1,22 @@ + + + + {#each element.props.items ?? [] as item, i} + + {item.title} + +

{item.content}

+
+
+ {/each} +
diff --git a/examples/svelte-chat/src/lib/render/components/Alert.svelte b/examples/svelte-chat/src/lib/render/components/Alert.svelte new file mode 100644 index 00000000..f3bf8466 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Alert.svelte @@ -0,0 +1,19 @@ + + + + {element.props.title} + {#if element.props.description} + {element.props.description} + {/if} + diff --git a/examples/svelte-chat/src/lib/render/components/Badge.svelte b/examples/svelte-chat/src/lib/render/components/Badge.svelte new file mode 100644 index 00000000..03088a54 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Badge.svelte @@ -0,0 +1,13 @@ + + +{element.props.text} diff --git a/examples/svelte-chat/src/lib/render/components/BarChart.svelte b/examples/svelte-chat/src/lib/render/components/BarChart.svelte new file mode 100644 index 00000000..a8238c48 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/BarChart.svelte @@ -0,0 +1,99 @@ + + +
+ {#if element.props.title} +

{element.props.title}

+ {/if} + + {#if chartData.items.length === 0} +
No data available
+ {:else} +
+ {#each chartData.items as item} +
+ {item.label} +
+
+
+ {item.value.toLocaleString()} +
+ {/each} +
+ {/if} +
diff --git a/examples/svelte-chat/src/lib/render/components/Button.svelte b/examples/svelte-chat/src/lib/render/components/Button.svelte new file mode 100644 index 00000000..2fc4a6a7 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Button.svelte @@ -0,0 +1,22 @@ + + + diff --git a/examples/svelte-chat/src/lib/render/components/Callout.svelte b/examples/svelte-chat/src/lib/render/components/Callout.svelte new file mode 100644 index 00000000..78343e31 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Callout.svelte @@ -0,0 +1,57 @@ + + +
+
+ {#if element.props.type === "tip"} + + {:else if element.props.type === "warning"} + + {:else if element.props.type === "important"} + + {:else} + + {/if} +
+ {#if element.props.title} +

{element.props.title}

+ {/if} +

{element.props.content}

+
+
+
diff --git a/examples/svelte-chat/src/lib/render/components/Card.svelte b/examples/svelte-chat/src/lib/render/components/Card.svelte new file mode 100644 index 00000000..5d826d30 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Card.svelte @@ -0,0 +1,32 @@ + + + + {#if element.props.title || element.props.description} + + {#if element.props.title} + {element.props.title} + {/if} + {#if element.props.description} + {element.props.description} + {/if} + + {/if} + + {#if children} + {@render children()} + {/if} + + diff --git a/examples/svelte-chat/src/lib/render/components/Grid.svelte b/examples/svelte-chat/src/lib/render/components/Grid.svelte new file mode 100644 index 00000000..5cfd947a --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Grid.svelte @@ -0,0 +1,31 @@ + + +
+ {#if children} + {@render children()} + {/if} +
diff --git a/examples/svelte-chat/src/lib/render/components/Heading.svelte b/examples/svelte-chat/src/lib/render/components/Heading.svelte new file mode 100644 index 00000000..a40fb9bc --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Heading.svelte @@ -0,0 +1,22 @@ + + +{#if level === "h1"} +

{element.props.text}

+{:else if level === "h2"} +

{element.props.text}

+{:else if level === "h3"} +

{element.props.text}

+{:else} +

{element.props.text}

+{/if} diff --git a/examples/svelte-chat/src/lib/render/components/LineChart.svelte b/examples/svelte-chat/src/lib/render/components/LineChart.svelte new file mode 100644 index 00000000..8a19244c --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/LineChart.svelte @@ -0,0 +1,109 @@ + + +
+ {#if element.props.title} +

{element.props.title}

+ {/if} + + {#if chartData.length === 0} +
No data available
+ {:else} +
+ + + +
+ {#each chartData.filter((_, i) => i === 0 || i === chartData.length - 1 || i === Math.floor(chartData.length / 2)) as item} + {item.label} + {/each} +
+
+ {/if} +
diff --git a/examples/svelte-chat/src/lib/render/components/Link.svelte b/examples/svelte-chat/src/lib/render/components/Link.svelte new file mode 100644 index 00000000..a05126a8 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Link.svelte @@ -0,0 +1,19 @@ + + + + {element.props.text} + diff --git a/examples/svelte-chat/src/lib/render/components/Metric.svelte b/examples/svelte-chat/src/lib/render/components/Metric.svelte new file mode 100644 index 00000000..ee6296e4 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Metric.svelte @@ -0,0 +1,40 @@ + + +
+

{element.props.label}

+
+ {element.props.value} + {#if element.props.trend} + {#if element.props.trend === "up"} + + {:else if element.props.trend === "down"} + + {:else} + + {/if} + {/if} +
+ {#if element.props.detail} +

{element.props.detail}

+ {/if} +
diff --git a/examples/svelte-chat/src/lib/render/components/PieChart.svelte b/examples/svelte-chat/src/lib/render/components/PieChart.svelte new file mode 100644 index 00000000..53bc294c --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/PieChart.svelte @@ -0,0 +1,106 @@ + + +
+ {#if element.props.title} +

{element.props.title}

+ {/if} + + {#if items.length === 0} +
No data available
+ {:else} +
+ + {#each segments() as seg} + {#if seg.endAngle - seg.startAngle >= 1} + + {/if} + {/each} + +
+ {#each segments() as seg} +
+ + {seg.name} + {seg.percentage}% +
+ {/each} +
+
+ {/if} +
diff --git a/examples/svelte-chat/src/lib/render/components/Progress.svelte b/examples/svelte-chat/src/lib/render/components/Progress.svelte new file mode 100644 index 00000000..b4cbf875 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Progress.svelte @@ -0,0 +1,13 @@ + + + diff --git a/examples/svelte-chat/src/lib/render/components/RadioGroup.svelte b/examples/svelte-chat/src/lib/render/components/RadioGroup.svelte new file mode 100644 index 00000000..b1ec73c0 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/RadioGroup.svelte @@ -0,0 +1,45 @@ + + +
+ {#if element.props.label} + + {/if} + + {#each element.props.options ?? [] as opt} +
+ + +
+ {/each} +
+
diff --git a/examples/svelte-chat/src/lib/render/components/SelectInput.svelte b/examples/svelte-chat/src/lib/render/components/SelectInput.svelte new file mode 100644 index 00000000..40e7bbbb --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/SelectInput.svelte @@ -0,0 +1,56 @@ + + +
+ {#if element.props.label} + + {/if} + + + {#if selectedOption} + {selectedOption.label} + {:else} + {element.props.placeholder ?? "Select..."} + {/if} + + + {#each element.props.options ?? [] as opt} + {opt.label} + {/each} + + +
diff --git a/examples/svelte-chat/src/lib/render/components/Separator.svelte b/examples/svelte-chat/src/lib/render/components/Separator.svelte new file mode 100644 index 00000000..c178a34a --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Separator.svelte @@ -0,0 +1,10 @@ + + + diff --git a/examples/svelte-chat/src/lib/render/components/Skeleton.svelte b/examples/svelte-chat/src/lib/render/components/Skeleton.svelte new file mode 100644 index 00000000..1b7d53d7 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Skeleton.svelte @@ -0,0 +1,14 @@ + + +
diff --git a/examples/svelte-chat/src/lib/render/components/Stack.svelte b/examples/svelte-chat/src/lib/render/components/Stack.svelte new file mode 100644 index 00000000..c6c3e04e --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Stack.svelte @@ -0,0 +1,28 @@ + + +
+ {#if children} + {@render children()} + {/if} +
diff --git a/examples/svelte-chat/src/lib/render/components/TabContent.svelte b/examples/svelte-chat/src/lib/render/components/TabContent.svelte new file mode 100644 index 00000000..6c8fa367 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/TabContent.svelte @@ -0,0 +1,19 @@ + + + + {#if children} + {@render children()} + {/if} + diff --git a/examples/svelte-chat/src/lib/render/components/Table.svelte b/examples/svelte-chat/src/lib/render/components/Table.svelte new file mode 100644 index 00000000..daf2d5e4 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Table.svelte @@ -0,0 +1,91 @@ + + +{#if items.length === 0} +
+ {element.props.emptyMessage ?? "No data"} +
+{:else} + + + + {#each element.props.columns as col} + + + + {/each} + + + + {#each sorted as item, i} + + {#each element.props.columns as col} + {String(item[col.key] ?? "")} + {/each} + + {/each} + + +{/if} diff --git a/examples/svelte-chat/src/lib/render/components/Tabs.svelte b/examples/svelte-chat/src/lib/render/components/Tabs.svelte new file mode 100644 index 00000000..80581480 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Tabs.svelte @@ -0,0 +1,29 @@ + + + + + {#each element.props.tabs ?? [] as tab} + {tab.label} + {/each} + + {#if children} + {@render children()} + {/if} + diff --git a/examples/svelte-chat/src/lib/render/components/Text.svelte b/examples/svelte-chat/src/lib/render/components/Text.svelte new file mode 100644 index 00000000..78327260 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Text.svelte @@ -0,0 +1,14 @@ + + +

+ {element.props.content} +

diff --git a/examples/svelte-chat/src/lib/render/components/TextInput.svelte b/examples/svelte-chat/src/lib/render/components/TextInput.svelte new file mode 100644 index 00000000..74c41ed4 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/TextInput.svelte @@ -0,0 +1,43 @@ + + +
+ {#if element.props.label} + + {/if} + +
diff --git a/examples/svelte-chat/src/lib/render/components/Timeline.svelte b/examples/svelte-chat/src/lib/render/components/Timeline.svelte new file mode 100644 index 00000000..1c7320e0 --- /dev/null +++ b/examples/svelte-chat/src/lib/render/components/Timeline.svelte @@ -0,0 +1,44 @@ + + +
+
+
+ {#each element.props.items ?? [] as item} +
+
+
+
+

{item.title}

+ {#if item.date} + + {item.date} + + {/if} +
+ {#if item.description} +

{item.description}

+ {/if} +
+
+ {/each} +
+
diff --git a/examples/svelte-chat/src/lib/render/registry.ts b/examples/svelte-chat/src/lib/render/registry.ts new file mode 100644 index 00000000..7802bb1f --- /dev/null +++ b/examples/svelte-chat/src/lib/render/registry.ts @@ -0,0 +1,61 @@ +import { defineRegistry, type ComponentRegistry } from "@json-render/svelte"; +import { explorerCatalog } from "./catalog"; + +// Import render components +import StackComponent from "./components/Stack.svelte"; +import CardComponent from "./components/Card.svelte"; +import GridComponent from "./components/Grid.svelte"; +import HeadingComponent from "./components/Heading.svelte"; +import TextComponent from "./components/Text.svelte"; +import BadgeComponent from "./components/Badge.svelte"; +import AlertComponent from "./components/Alert.svelte"; +import SeparatorComponent from "./components/Separator.svelte"; +import MetricComponent from "./components/Metric.svelte"; +import TableComponent from "./components/Table.svelte"; +import LinkComponent from "./components/Link.svelte"; +import BarChartComponent from "./components/BarChart.svelte"; +import LineChartComponent from "./components/LineChart.svelte"; +import TabsComponent from "./components/Tabs.svelte"; +import TabContentComponent from "./components/TabContent.svelte"; +import ProgressComponent from "./components/Progress.svelte"; +import SkeletonComponent from "./components/Skeleton.svelte"; +import CalloutComponent from "./components/Callout.svelte"; +import AccordionComponent from "./components/Accordion.svelte"; +import TimelineComponent from "./components/Timeline.svelte"; +import PieChartComponent from "./components/PieChart.svelte"; +import RadioGroupComponent from "./components/RadioGroup.svelte"; +import SelectInputComponent from "./components/SelectInput.svelte"; +import TextInputComponent from "./components/TextInput.svelte"; +import ButtonComponent from "./components/Button.svelte"; + +const components: ComponentRegistry = { + Stack: StackComponent, + Card: CardComponent, + Grid: GridComponent, + Heading: HeadingComponent, + Text: TextComponent, + Badge: BadgeComponent, + Alert: AlertComponent, + Separator: SeparatorComponent, + Metric: MetricComponent, + Table: TableComponent, + Link: LinkComponent, + BarChart: BarChartComponent, + LineChart: LineChartComponent, + Tabs: TabsComponent, + TabContent: TabContentComponent, + Progress: ProgressComponent, + Skeleton: SkeletonComponent, + Callout: CalloutComponent, + Accordion: AccordionComponent, + Timeline: TimelineComponent, + PieChart: PieChartComponent, + RadioGroup: RadioGroupComponent, + SelectInput: SelectInputComponent, + TextInput: TextInputComponent, + Button: ButtonComponent, +}; + +export const { registry } = defineRegistry(explorerCatalog, { + components, +}); diff --git a/examples/svelte-chat/src/lib/tools/crypto.ts b/examples/svelte-chat/src/lib/tools/crypto.ts new file mode 100644 index 00000000..60fd15c3 --- /dev/null +++ b/examples/svelte-chat/src/lib/tools/crypto.ts @@ -0,0 +1,165 @@ +import { tool } from "ai"; +import { z } from "zod"; + +// ============================================================================= +// Helpers +// ============================================================================= + +function handleFetchError(res: Response, coinId: string) { + if (res.status === 404) { + return { error: `Cryptocurrency not found: ${coinId}` }; + } + if (res.status === 429) { + return { error: "CoinGecko rate limit exceeded. Try again in a minute." }; + } + return { error: `Failed to fetch crypto data: ${res.statusText}` }; +} + +function sampleTimeSeries( + prices: [number, number][], + maxPoints: number, +): Array<{ date: string; price: number }> { + const step = Math.max(1, Math.floor(prices.length / maxPoints)); + return prices + .filter((_, i) => i % step === 0) + .map(([timestamp, price]) => ({ + date: new Date(timestamp).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + price: Math.round(price * 100) / 100, + })); +} + +// ============================================================================= +// getCryptoPrice — current market data + 7-day sparkline +// ============================================================================= + +/** + * Get cryptocurrency market data from CoinGecko. + * Free public API, no API key required. + * https://docs.coingecko.com/reference/introduction + */ +export const getCryptoPrice = tool({ + description: + "Get current price, market cap, 24h change, and 7-day sparkline for a cryptocurrency. For longer price history (30d, 90d, 365d), use getCryptoPriceHistory instead.", + inputSchema: z.object({ + coinId: z + .string() + .describe( + "CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana', 'dogecoin', 'cardano')", + ), + }), + execute: async ({ coinId }) => { + const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}?localization=false&tickers=false&community_data=false&developer_data=false&sparkline=true`; + + const res = await fetch(url, { + headers: { Accept: "application/json" }, + }); + + if (!res.ok) return handleFetchError(res, coinId); + + const data = (await res.json()) as { + id: string; + symbol: string; + name: string; + market_data: { + current_price: { usd: number }; + market_cap: { usd: number }; + total_volume: { usd: number }; + price_change_percentage_24h: number; + price_change_percentage_7d: number; + price_change_percentage_30d: number; + high_24h: { usd: number }; + low_24h: { usd: number }; + ath: { usd: number }; + ath_date: { usd: string }; + circulating_supply: number; + total_supply: number | null; + sparkline_7d: { price: number[] }; + }; + market_cap_rank: number; + }; + + const md = data.market_data; + + // Convert sparkline (hourly array) to dated points + const now = Date.now(); + const sparkline = md.sparkline_7d.price; + const step = Math.max(1, Math.floor(sparkline.length / 14)); + const sparklineData = sparkline + .filter((_, i) => i % step === 0) + .map((price, i) => { + const hourIndex = i * step; + const ts = now - (sparkline.length - hourIndex) * 3600_000; + return { + date: new Date(ts).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + price: Math.round(price * 100) / 100, + }; + }); + + return { + id: data.id, + symbol: data.symbol.toUpperCase(), + name: data.name, + rank: data.market_cap_rank, + price: md.current_price.usd, + marketCap: md.market_cap.usd, + volume24h: md.total_volume.usd, + change24h: Math.round(md.price_change_percentage_24h * 100) / 100, + change7d: Math.round(md.price_change_percentage_7d * 100) / 100, + change30d: Math.round(md.price_change_percentage_30d * 100) / 100, + high24h: md.high_24h.usd, + low24h: md.low_24h.usd, + allTimeHigh: md.ath.usd, + allTimeHighDate: md.ath_date.usd, + circulatingSupply: md.circulating_supply, + totalSupply: md.total_supply, + sparkline7d: sparklineData, + }; + }, +}); + +// ============================================================================= +// getCryptoPriceHistory — flexible date range price history +// ============================================================================= + +export const getCryptoPriceHistory = tool({ + description: + "Get historical price data for a cryptocurrency over a specified number of days (e.g., 30, 90, 365). Returns date-labeled data points suitable for charting.", + inputSchema: z.object({ + coinId: z + .string() + .describe("CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana')"), + days: z + .number() + .int() + .min(1) + .max(365) + .describe("Number of days of history to fetch (e.g., 30, 90, 365)"), + }), + execute: async ({ coinId, days }) => { + const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}/market_chart?vs_currency=usd&days=${days}`; + + const res = await fetch(url, { + headers: { Accept: "application/json" }, + }); + + if (!res.ok) return handleFetchError(res, coinId); + + const data = (await res.json()) as { + prices: [number, number][]; + }; + + const priceHistory = sampleTimeSeries(data.prices, 20); + + return { + coinId, + days, + priceHistory, + }; + }, +}); diff --git a/examples/svelte-chat/src/lib/tools/github.ts b/examples/svelte-chat/src/lib/tools/github.ts new file mode 100644 index 00000000..a0d0980e --- /dev/null +++ b/examples/svelte-chat/src/lib/tools/github.ts @@ -0,0 +1,237 @@ +import { tool } from "ai"; +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +const ghHeaders = { Accept: "application/vnd.github.v3+json" }; + +function handleGitHubError(res: Response, context: string) { + if (res.status === 404) return { error: `Not found: ${context}` }; + if (res.status === 403) + return { error: "GitHub API rate limit exceeded. Try again later." }; + return { error: `Failed to fetch ${context}: ${res.statusText}` }; +} + +// --------------------------------------------------------------------------- +// getGitHubRepo +// --------------------------------------------------------------------------- + +/** + * Get public GitHub repository information. + * Uses the public GitHub REST API (no auth, 60 req/hr rate limit). + */ +export const getGitHubRepo = tool({ + description: + "Get information about a public GitHub repository including stars, forks, open issues, description, language, and recent activity.", + inputSchema: z.object({ + owner: z.string().describe("Repository owner (e.g., 'vercel')"), + repo: z.string().describe("Repository name (e.g., 'next.js')"), + }), + execute: async ({ owner, repo }) => { + const repoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + + const [repoRes, languagesRes] = await Promise.all([ + fetch(repoUrl, { headers: ghHeaders }), + fetch(`${repoUrl}/languages`, { headers: ghHeaders }), + ]); + + if (!repoRes.ok) { + return handleGitHubError(repoRes, `${owner}/${repo}`); + } + + const repoData = (await repoRes.json()) as { + full_name: string; + description: string | null; + html_url: string; + stargazers_count: number; + forks_count: number; + open_issues_count: number; + watchers_count: number; + language: string | null; + license: { spdx_id: string } | null; + created_at: string; + updated_at: string; + pushed_at: string; + topics: string[]; + size: number; + default_branch: string; + archived: boolean; + fork: boolean; + }; + + const languages: Record = languagesRes.ok + ? ((await languagesRes.json()) as Record) + : {}; + + const totalBytes = Object.values(languages).reduce((a, b) => a + b, 0); + const languageBreakdown = Object.entries(languages) + .map(([lang, bytes]) => ({ + language: lang, + percentage: Math.round((bytes / totalBytes) * 100), + bytes, + })) + .sort((a, b) => b.bytes - a.bytes) + .slice(0, 8); + + return { + name: repoData.full_name, + description: repoData.description, + url: repoData.html_url, + stars: repoData.stargazers_count, + forks: repoData.forks_count, + openIssues: repoData.open_issues_count, + watchers: repoData.watchers_count, + primaryLanguage: repoData.language, + license: repoData.license?.spdx_id ?? "None", + createdAt: repoData.created_at, + updatedAt: repoData.updated_at, + lastPush: repoData.pushed_at, + topics: repoData.topics, + defaultBranch: repoData.default_branch, + archived: repoData.archived, + isFork: repoData.fork, + languages: languageBreakdown, + }; + }, +}); + +// --------------------------------------------------------------------------- +// getGitHubPullRequests +// --------------------------------------------------------------------------- + +type GitHubPR = { + number: number; + title: string; + state: string; + html_url: string; + user: { login: string } | null; + created_at: string; + updated_at: string; + merged_at: string | null; + comments: number; + labels: Array<{ name: string }>; + draft: boolean; +}; + +type GitHubPRReview = { + id: number; +}; + +type GitHubPRReaction = { + total_count: number; +}; + +/** + * Get pull requests from a public GitHub repository. + * Supports filtering by state and sorting by various criteria. + * Fetches comment counts and reactions for ranking "most popular" PRs. + */ +export const getGitHubPullRequests = tool({ + description: + "Get pull requests from a public GitHub repository. Returns titles, authors, state, comment counts, and reactions. Use sort='popularity' to find the most discussed / reacted PRs.", + inputSchema: z.object({ + owner: z.string().describe("Repository owner (e.g., 'vercel')"), + repo: z.string().describe("Repository name (e.g., 'next.js')"), + state: z + .enum(["open", "closed", "all"]) + .nullable() + .describe("Filter by state. Defaults to 'open'."), + sort: z + .enum(["created", "updated", "popularity", "long-running"]) + .nullable() + .describe( + "Sort order. 'popularity' sorts by reactions+comments, 'long-running' sorts by age. Defaults to 'created'.", + ), + perPage: z + .number() + .int() + .min(1) + .max(30) + .nullable() + .describe("Number of PRs to return (1-30). Defaults to 10."), + }), + execute: async ({ owner, repo, state, sort, perPage }) => { + const count = perPage ?? 10; + const prState = state ?? "open"; + + // GitHub API sort param: 'popularity' and 'long-running' are API-native + const apiSort = + sort === "popularity" + ? "popularity" + : sort === "long-running" + ? "long-running" + : sort === "updated" + ? "updated" + : "created"; + + const url = new URL( + `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, + ); + url.searchParams.set("state", prState); + url.searchParams.set("sort", apiSort); + url.searchParams.set("direction", "desc"); + url.searchParams.set("per_page", String(count)); + + const res = await fetch(url.toString(), { headers: ghHeaders }); + + if (!res.ok) { + return handleGitHubError(res, `${owner}/${repo} pull requests`); + } + + const prs = (await res.json()) as GitHubPR[]; + + // Fetch review + reaction counts in parallel for richer data + const enriched = await Promise.all( + prs.map(async (pr) => { + const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${pr.number}`; + + const [reviewsRes, reactionsRes] = await Promise.all([ + fetch(`${base}/reviews?per_page=100`, { headers: ghHeaders }), + fetch( + `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${pr.number}/reactions`, + { + headers: { + ...ghHeaders, + Accept: "application/vnd.github.squirrel-girl-preview+json", + }, + }, + ), + ]); + + const reviews: GitHubPRReview[] = reviewsRes.ok + ? ((await reviewsRes.json()) as GitHubPRReview[]) + : []; + + let reactionCount = 0; + if (reactionsRes.ok) { + const reactions = (await reactionsRes.json()) as GitHubPRReaction[]; + reactionCount = reactions.length; + } + + return { + number: pr.number, + title: pr.title, + state: pr.merged_at ? "merged" : pr.state, + author: pr.user?.login ?? "unknown", + url: pr.html_url, + createdAt: pr.created_at, + updatedAt: pr.updated_at, + comments: pr.comments, + reviews: reviews.length, + reactions: reactionCount, + labels: pr.labels.map((l) => l.name), + draft: pr.draft, + }; + }), + ); + + return { + repository: `${owner}/${repo}`, + state: prState, + count: enriched.length, + pullRequests: enriched, + }; + }, +}); diff --git a/examples/svelte-chat/src/lib/tools/hackernews.ts b/examples/svelte-chat/src/lib/tools/hackernews.ts new file mode 100644 index 00000000..e6562251 --- /dev/null +++ b/examples/svelte-chat/src/lib/tools/hackernews.ts @@ -0,0 +1,67 @@ +import { tool } from "ai"; +import { z } from "zod"; + +/** + * Get top stories from Hacker News. + * Uses the official HN Firebase API. Free, no auth required. + * https://github.com/HackerNewsAPI/API + */ +export const getHackerNewsTop = tool({ + description: + "Get the current top stories from Hacker News, including title, score, author, URL, and comment count.", + inputSchema: z.object({ + count: z + .number() + .min(1) + .max(30) + .describe("Number of top stories to fetch (1-30)"), + }), + execute: async ({ count }) => { + const topUrl = + "https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty"; + const topRes = await fetch(topUrl); + + if (!topRes.ok) { + return { error: "Failed to fetch Hacker News top stories" }; + } + + const topIds = (await topRes.json()) as number[]; + const storyIds = topIds.slice(0, count); + + const stories = await Promise.all( + storyIds.map(async (id) => { + const storyRes = await fetch( + `https://hacker-news.firebaseio.com/v0/item/${id}.json?print=pretty`, + ); + if (!storyRes.ok) return null; + + const story = (await storyRes.json()) as { + id: number; + title: string; + url?: string; + score: number; + by: string; + time: number; + descendants?: number; + type: string; + }; + + return { + id: story.id, + title: story.title, + url: story.url ?? `https://news.ycombinator.com/item?id=${story.id}`, + score: story.score, + author: story.by, + comments: story.descendants ?? 0, + postedAt: new Date(story.time * 1000).toISOString(), + hnUrl: `https://news.ycombinator.com/item?id=${story.id}`, + }; + }), + ); + + return { + stories: stories.filter(Boolean), + fetchedAt: new Date().toISOString(), + }; + }, +}); diff --git a/examples/svelte-chat/src/lib/tools/search.ts b/examples/svelte-chat/src/lib/tools/search.ts new file mode 100644 index 00000000..9ff7105b --- /dev/null +++ b/examples/svelte-chat/src/lib/tools/search.ts @@ -0,0 +1,36 @@ +import { tool, generateText } from "ai"; +import { gateway } from "@ai-sdk/gateway"; +import { z } from "zod"; + +/** + * Web search tool using Perplexity Sonar via AI Gateway. + * + * Perplexity Sonar models have built-in internet access and return + * synthesized answers with citations. This is wrapped as a regular tool + * (with an `execute` function) so that ToolLoopAgent can loop: it calls + * the model, gets results, and feeds them back for the next step. + */ +export const webSearch = tool({ + description: + "Search the web for current information on any topic. Use this when the user asks about something not covered by the specialized tools (weather, crypto, GitHub, Hacker News). Returns a synthesized answer based on real-time web data.", + inputSchema: z.object({ + query: z + .string() + .describe( + "The search query — be specific and include relevant context for better results", + ), + }), + execute: async ({ query }) => { + try { + const { text } = await generateText({ + model: gateway("perplexity/sonar"), + prompt: query, + }); + return { content: text }; + } catch (error) { + return { + error: `Search failed: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } + }, +}); diff --git a/examples/svelte-chat/src/lib/tools/weather.ts b/examples/svelte-chat/src/lib/tools/weather.ts new file mode 100644 index 00000000..b9d90f8d --- /dev/null +++ b/examples/svelte-chat/src/lib/tools/weather.ts @@ -0,0 +1,126 @@ +import { tool } from "ai"; +import { z } from "zod"; + +/** + * Get current weather and 7-day forecast for a city using Open-Meteo API. + * Free, no API key required. + * https://open-meteo.com/ + */ +export const getWeather = tool({ + description: + "Get current weather conditions and a 7-day forecast for a given city. Returns temperature, humidity, wind speed, weather conditions, and daily forecasts.", + inputSchema: z.object({ + city: z + .string() + .describe("City name (e.g., 'New York', 'London', 'Tokyo')"), + }), + execute: async ({ city }) => { + // Step 1: Geocode the city name to coordinates + const geocodeUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`; + const geocodeRes = await fetch(geocodeUrl); + + if (!geocodeRes.ok) { + return { error: `Failed to geocode city: ${city}` }; + } + + const geocodeData = (await geocodeRes.json()) as { + results?: Array<{ + name: string; + country: string; + latitude: number; + longitude: number; + timezone: string; + }>; + }; + + if (!geocodeData.results || geocodeData.results.length === 0) { + return { error: `City not found: ${city}` }; + } + + const location = geocodeData.results[0]!; + + // Step 2: Get weather data + const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=${encodeURIComponent(location.timezone)}&forecast_days=7`; + + const weatherRes = await fetch(weatherUrl); + + if (!weatherRes.ok) { + return { error: "Failed to fetch weather data" }; + } + + const weather = (await weatherRes.json()) as { + current: { + temperature_2m: number; + relative_humidity_2m: number; + apparent_temperature: number; + weather_code: number; + wind_speed_10m: number; + }; + daily: { + time: string[]; + weather_code: number[]; + temperature_2m_max: number[]; + temperature_2m_min: number[]; + precipitation_sum: number[]; + }; + }; + + const weatherDescription = describeWeatherCode( + weather.current.weather_code, + ); + + const forecast = weather.daily.time.map((date, i) => ({ + date, + day: new Date(date + "T12:00:00").toLocaleDateString("en-US", { + weekday: "short", + }), + high: Math.round(weather.daily.temperature_2m_max[i]!), + low: Math.round(weather.daily.temperature_2m_min[i]!), + condition: describeWeatherCode(weather.daily.weather_code[i]!), + precipitation: weather.daily.precipitation_sum[i]!, + })); + + return { + city: location.name, + country: location.country, + current: { + temperature: Math.round(weather.current.temperature_2m), + feelsLike: Math.round(weather.current.apparent_temperature), + humidity: weather.current.relative_humidity_2m, + windSpeed: Math.round(weather.current.wind_speed_10m), + condition: weatherDescription, + }, + forecast, + }; + }, +}); + +function describeWeatherCode(code: number): string { + const descriptions: Record = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Foggy", + 48: "Depositing rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 61: "Slight rain", + 63: "Moderate rain", + 65: "Heavy rain", + 71: "Slight snow", + 73: "Moderate snow", + 75: "Heavy snow", + 77: "Snow grains", + 80: "Slight rain showers", + 81: "Moderate rain showers", + 82: "Violent rain showers", + 85: "Slight snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm with slight hail", + 99: "Thunderstorm with heavy hail", + }; + return descriptions[code] ?? "Unknown"; +} diff --git a/examples/svelte-chat/src/lib/utils.ts b/examples/svelte-chat/src/lib/utils.ts new file mode 100644 index 00000000..a091ef10 --- /dev/null +++ b/examples/svelte-chat/src/lib/utils.ts @@ -0,0 +1,18 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import type { Snippet } from "svelte"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// Utility types for shadcn-svelte components +export type WithElementRef = T & { + ref?: E | null; +}; + +// WithoutChild should only omit "child" (singular), not "children" +// children is the Svelte 5 snippet pattern, which is still used +export type WithoutChild = Omit & { children?: Snippet }; + +export type WithoutChildrenOrChild = Omit; diff --git a/examples/svelte-chat/src/routes/+layout.svelte b/examples/svelte-chat/src/routes/+layout.svelte new file mode 100644 index 00000000..bfd3ebdd --- /dev/null +++ b/examples/svelte-chat/src/routes/+layout.svelte @@ -0,0 +1,13 @@ + + + + + json-render Svelte Chat + + +{@render children()} diff --git a/examples/svelte-chat/src/routes/+page.svelte b/examples/svelte-chat/src/routes/+page.svelte new file mode 100644 index 00000000..efbfe801 --- /dev/null +++ b/examples/svelte-chat/src/routes/+page.svelte @@ -0,0 +1,381 @@ + + +
+ +
+
+

json-render Svelte Chat

+
+
+ {#if chat.messages.length > 0} + + {/if} +
+
+ + +
+ {#if isEmpty} + +
+
+
+

+ What would you like to explore? +

+

+ Ask about weather, GitHub repos, crypto prices, or Hacker News -- + the agent will fetch real data and build a dashboard. +

+
+ + +
+ {#each SUGGESTIONS as s} + + {/each} +
+
+
+ {:else} + +
+ {#each chat.messages as message, index} + {@const isLast = index === chat.messages.length - 1} + {@const parts = message.parts as DataPart[]} + {@const spec = getSpec(parts)} + {@const text = getText(parts)} + {@const messageHasSpec = hasSpec(parts)} + {@const { segments, specInserted } = getSegments(parts)} + + {#if message.role === "user"} + +
+ {#if text} +
+ {text} +
+ {/if} +
+ {:else} + + {@const hasAnything = segments.length > 0 || messageHasSpec} + {@const showLoader = isLast && isStreaming && !hasAnything} + {@const showSpecAtEnd = messageHasSpec && !specInserted} + +
+ {#each segments as seg, i} + {#if seg.kind === "text"} +
+ {seg.text} +
+ {:else if seg.kind === "spec"} + {#if spec} +
+ +
+ {/if} + {:else if seg.kind === "tools"} +
+ {#each seg.tools as t} + {@const toolIsLoading = + t.state !== "output-available" && + t.state !== "output-error" && + t.state !== "output-denied"} + {@const labels = TOOL_LABELS[t.toolName]} + {@const label = labels + ? toolIsLoading + ? labels[0] + : labels[1] + : t.toolName} + +
+ + {label} + +
+ {/each} +
+ {/if} + {/each} + + + {#if showLoader} +
+ Thinking... +
+ {/if} + + + {#if showSpecAtEnd && spec} +
+ +
+ {/if} +
+ {/if} + {/each} + + + {#if chat.error} +
+ {chat.error.message} +
+ {/if} +
+ {/if} +
+ + +
+ + {#if showScrollButton && !isEmpty} + + {/if} + +
+ + +
+
+
diff --git a/examples/svelte-chat/src/routes/api/generate/+server.ts b/examples/svelte-chat/src/routes/api/generate/+server.ts new file mode 100644 index 00000000..bfbf51c4 --- /dev/null +++ b/examples/svelte-chat/src/routes/api/generate/+server.ts @@ -0,0 +1,35 @@ +import { agent } from "$lib/agent"; +import { + convertToModelMessages, + createUIMessageStream, + createUIMessageStreamResponse, + type UIMessage, +} from "ai"; +import { pipeJsonRender } from "@json-render/core"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json(); + const uiMessages: UIMessage[] = body.messages; + + if (!uiMessages || !Array.isArray(uiMessages) || uiMessages.length === 0) { + return new Response( + JSON.stringify({ error: "messages array is required" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const modelMessages = await convertToModelMessages(uiMessages); + const result = await agent.stream({ messages: modelMessages }); + + const stream = createUIMessageStream({ + execute: async ({ writer }) => { + writer.merge(pipeJsonRender(result.toUIMessageStream())); + }, + }); + + return createUIMessageStreamResponse({ stream }); +}; diff --git a/examples/svelte-chat/static/robots.txt b/examples/svelte-chat/static/robots.txt new file mode 100644 index 00000000..b6dd6670 --- /dev/null +++ b/examples/svelte-chat/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/examples/svelte-chat/svelte.config.js b/examples/svelte-chat/svelte.config.js new file mode 100644 index 00000000..1cc76be9 --- /dev/null +++ b/examples/svelte-chat/svelte.config.js @@ -0,0 +1,10 @@ +import adapter from "@sveltejs/adapter-auto"; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter(), + }, +}; + +export default config; diff --git a/examples/svelte-chat/tsconfig.json b/examples/svelte-chat/tsconfig.json new file mode 100644 index 00000000..2c2ed3c4 --- /dev/null +++ b/examples/svelte-chat/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/examples/svelte-chat/vite.config.ts b/examples/svelte-chat/vite.config.ts new file mode 100644 index 00000000..0d65de7a --- /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: [sveltekit(), tailwindcss()], + optimizeDeps: { + exclude: ["@json-render/svelte"], + }, +}); diff --git a/examples/svelte/index.html b/examples/svelte/index.html new file mode 100644 index 00000000..1e0ab775 --- /dev/null +++ b/examples/svelte/index.html @@ -0,0 +1,12 @@ + + + + + + json-render svelte example + + +
+ + + diff --git a/examples/svelte/package.json b/examples/svelte/package.json new file mode 100644 index 00000000..d3c66808 --- /dev/null +++ b/examples/svelte/package.json @@ -0,0 +1,24 @@ +{ + "name": "example-svelte", + "version": "0.1.1", + "private": true, + "type": "module", + "scripts": { + "dev": " vite", + "build": "vite build", + "preview": "vite preview", + "check-types": "svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@json-render/core": "workspace:*", + "@json-render/svelte": "workspace:*", + "svelte": "^5.49.2", + "zod": "4.3.5" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte-check": "^4.3.6", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} diff --git a/examples/svelte/src/App.svelte b/examples/svelte/src/App.svelte new file mode 100644 index 00000000..15a5e636 --- /dev/null +++ b/examples/svelte/src/App.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/examples/svelte/src/DemoRenderer.svelte b/examples/svelte/src/DemoRenderer.svelte new file mode 100644 index 00000000..ed06fb12 --- /dev/null +++ b/examples/svelte/src/DemoRenderer.svelte @@ -0,0 +1,46 @@ + + + + + + + + + diff --git a/examples/svelte/src/app.css b/examples/svelte/src/app.css new file mode 100644 index 00000000..74138d82 --- /dev/null +++ b/examples/svelte/src/app.css @@ -0,0 +1,13 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #f9fafb; + color: #111827; + min-height: 100vh; +} diff --git a/examples/svelte/src/lib/catalog.ts b/examples/svelte/src/lib/catalog.ts new file mode 100644 index 00000000..1c5c959b --- /dev/null +++ b/examples/svelte/src/lib/catalog.ts @@ -0,0 +1,90 @@ +import { schema } from "@json-render/svelte/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", + }, + }, +}); diff --git a/examples/svelte/src/lib/components/Badge.svelte b/examples/svelte/src/lib/components/Badge.svelte new file mode 100644 index 00000000..c449f7dc --- /dev/null +++ b/examples/svelte/src/lib/components/Badge.svelte @@ -0,0 +1,25 @@ + + + + {_props.label} + diff --git a/examples/svelte/src/lib/components/Button.svelte b/examples/svelte/src/lib/components/Button.svelte new file mode 100644 index 00000000..3be13cf4 --- /dev/null +++ b/examples/svelte/src/lib/components/Button.svelte @@ -0,0 +1,45 @@ + + + diff --git a/examples/svelte/src/lib/components/Card.svelte b/examples/svelte/src/lib/components/Card.svelte new file mode 100644 index 00000000..d705f79a --- /dev/null +++ b/examples/svelte/src/lib/components/Card.svelte @@ -0,0 +1,48 @@ + + +
+ {#if _props.title} +

+ {_props.title} +

+ {/if} + {#if _props.subtitle} +

+ {_props.subtitle} +

+ {/if} + {#if children} + {@render children()} + {/if} +
diff --git a/examples/svelte/src/lib/components/Input.svelte b/examples/svelte/src/lib/components/Input.svelte new file mode 100644 index 00000000..af52e736 --- /dev/null +++ b/examples/svelte/src/lib/components/Input.svelte @@ -0,0 +1,29 @@ + + + diff --git a/examples/svelte/src/lib/components/ListItem.svelte b/examples/svelte/src/lib/components/ListItem.svelte new file mode 100644 index 00000000..e38f4076 --- /dev/null +++ b/examples/svelte/src/lib/components/ListItem.svelte @@ -0,0 +1,50 @@ + + + diff --git a/examples/svelte/src/lib/components/Stack.svelte b/examples/svelte/src/lib/components/Stack.svelte new file mode 100644 index 00000000..9ac37ad2 --- /dev/null +++ b/examples/svelte/src/lib/components/Stack.svelte @@ -0,0 +1,36 @@ + + +
+ {#if children} + {@render children()} + {/if} +
diff --git a/examples/svelte/src/lib/components/Text.svelte b/examples/svelte/src/lib/components/Text.svelte new file mode 100644 index 00000000..ca19695d --- /dev/null +++ b/examples/svelte/src/lib/components/Text.svelte @@ -0,0 +1,35 @@ + + + + {String(_props.content ?? "")} + diff --git a/examples/svelte/src/lib/registry.ts b/examples/svelte/src/lib/registry.ts new file mode 100644 index 00000000..80127420 --- /dev/null +++ b/examples/svelte/src/lib/registry.ts @@ -0,0 +1,22 @@ +import { defineRegistry, type ComponentRegistry } from "@json-render/svelte"; +import { catalog } from "./catalog"; + +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 Input from "./components/Input.svelte"; + +const components: ComponentRegistry = { + Stack, + Card, + Text, + Button, + Badge, + ListItem, + Input, +}; + +export const { registry } = defineRegistry(catalog, { components }); diff --git a/examples/svelte/src/lib/spec.ts b/examples/svelte/src/lib/spec.ts new file mode 100644 index 00000000..925b1ee0 --- /dev/null +++ b/examples/svelte/src/lib/spec.ts @@ -0,0 +1,130 @@ +import type { Spec } from "@json-render/core"; + +export const demoSpec: Spec = { + root: "root", + state: { + count: 0, + name: "", + todos: [ + { id: 1, title: "Learn Svelte 5", completed: true }, + { id: 2, title: "Try @json-render/svelte", 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/svelte demo", + size: "xl", + weight: "bold", + }, + }, + "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": { + type: "Badge", + props: { label: "Milestone reached: 10!", color: "#10b981" }, + visible: { $state: "/count", gte: 10 }, + }, + "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 } } }, + }, + }, + "input-card": { + type: "Card", + props: { + title: "Bound Input", + subtitle: "Type to update state and see reactive text", + }, + 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", + }, + }, + }, +}; diff --git a/examples/svelte/src/main.ts b/examples/svelte/src/main.ts new file mode 100644 index 00000000..817f168d --- /dev/null +++ b/examples/svelte/src/main.ts @@ -0,0 +1,9 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; +import "./app.css"; + +const app = mount(App, { + target: document.getElementById("app")!, +}); + +export default app; diff --git a/examples/svelte/src/vite-env.d.ts b/examples/svelte/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/svelte/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/svelte/svelte.config.js b/examples/svelte/svelte.config.js new file mode 100644 index 00000000..ff8b4c56 --- /dev/null +++ b/examples/svelte/svelte.config.js @@ -0,0 +1 @@ +export default {}; diff --git a/examples/svelte/tsconfig.json b/examples/svelte/tsconfig.json new file mode 100644 index 00000000..85649d6d --- /dev/null +++ b/examples/svelte/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + "files": ["src/DemoRenderer.svelte"], + "exclude": ["node_modules"] + // "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/examples/svelte/vite.config.ts b/examples/svelte/vite.config.ts new file mode 100644 index 00000000..8a6f4b5b --- /dev/null +++ b/examples/svelte/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +export default defineConfig({ + plugins: [svelte()], +}); diff --git a/examples/vite-renderers/package.json b/examples/vite-renderers/package.json index d88c2997..89b92a6a 100644 --- a/examples/vite-renderers/package.json +++ b/examples/vite-renderers/package.json @@ -2,6 +2,7 @@ "name": "vite-renderers", "version": "0.1.1", "private": true, + "type": "module", "scripts": { "dev": "portless vite-renderers.json-render vite", "build": "vite build", @@ -10,13 +11,16 @@ "dependencies": { "@json-render/core": "workspace:*", "@json-render/react": "workspace:*", + "@json-render/svelte": "workspace:*", "@json-render/vue": "workspace:*", "react": "^19.2.4", "react-dom": "^19.2.4", + "svelte": "^5.49.2", "vue": "^3.5.29", - "zod": "^4.3.6" + "zod": "4.3.5" }, "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", diff --git a/examples/vite-renderers/src/main.ts b/examples/vite-renderers/src/main.ts index 70ae8909..d764e2ec 100644 --- a/examples/vite-renderers/src/main.ts +++ b/examples/vite-renderers/src/main.ts @@ -1,7 +1,7 @@ import "./shared/styles.css"; import { demoSpec } from "./spec"; -type Renderer = "vue" | "react"; +type Renderer = "vue" | "react" | "svelte"; const container = document.getElementById("renderer-root") as HTMLElement; @@ -14,10 +14,14 @@ async function switchTo(renderer: Renderer) { const mod = await import("./vue/mount.ts"); mod.mount(container, renderer, demoSpec); unmountCurrent = mod.unmount; - } else { + } else if (renderer === "react") { const mod = await import("./react/mount.tsx"); mod.mount(container, renderer, demoSpec); unmountCurrent = mod.unmount; + } else { + const mod = await import("./svelte/mount.ts"); + mod.mount(container, renderer, demoSpec); + unmountCurrent = mod.unmount; } } diff --git a/examples/vite-renderers/src/react/registry.tsx b/examples/vite-renderers/src/react/registry.tsx index 14ab5b77..378deb24 100644 --- a/examples/vite-renderers/src/react/registry.tsx +++ b/examples/vite-renderers/src/react/registry.tsx @@ -119,7 +119,11 @@ export const components: Components = { RendererBadge: ({ props }) => ( - {props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React"} + {props.renderer === "vue" + ? "Rendered with Vue" + : props.renderer === "react" + ? "Rendered with React" + : "Rendered with Svelte"} ), @@ -149,6 +153,17 @@ export const components: Components = { > React + ), diff --git a/examples/vite-renderers/src/shared/catalog-def.ts b/examples/vite-renderers/src/shared/catalog-def.ts index e18bd487..d271f5e8 100644 --- a/examples/vite-renderers/src/shared/catalog-def.ts +++ b/examples/vite-renderers/src/shared/catalog-def.ts @@ -65,7 +65,7 @@ export const catalogDef = { props: z.object({ renderer: z.string() }), slots: [], description: - "Segmented tab control for switching between Vue and React renderers", + "Segmented tab control for switching between Vue, React, and Svelte renderers", }, RendererBadge: { props: z.object({ renderer: z.string() }), @@ -95,5 +95,9 @@ export const catalogDef = { params: z.object({}), description: "Switch to the React renderer", }, + switchToSvelte: { + params: z.object({}), + description: "Switch to the Svelte renderer", + }, }, }; diff --git a/examples/vite-renderers/src/shared/handlers.ts b/examples/vite-renderers/src/shared/handlers.ts index 5f83d29a..8bdc0149 100644 --- a/examples/vite-renderers/src/shared/handlers.ts +++ b/examples/vite-renderers/src/shared/handlers.ts @@ -9,6 +9,7 @@ export const actionStubs = { toggleItem: async () => {}, switchToVue: async () => {}, switchToReact: async () => {}, + switchToSvelte: async () => {}, }; /** Creates action handlers that close over the state store's get/set */ @@ -46,5 +47,10 @@ export function makeHandlers(get: Get, set: Set) { new CustomEvent("switch-renderer", { detail: "react" }), ); }, + switchToSvelte: async () => { + document.dispatchEvent( + new CustomEvent("switch-renderer", { detail: "svelte" }), + ); + }, }; } diff --git a/examples/vite-renderers/src/shared/styles.css b/examples/vite-renderers/src/shared/styles.css index c97ab6cb..414d11fb 100644 --- a/examples/vite-renderers/src/shared/styles.css +++ b/examples/vite-renderers/src/shared/styles.css @@ -246,3 +246,18 @@ 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; +} diff --git a/examples/vite-renderers/src/spec.ts b/examples/vite-renderers/src/spec.ts index e318143b..9b88256e 100644 --- a/examples/vite-renderers/src/spec.ts +++ b/examples/vite-renderers/src/spec.ts @@ -9,7 +9,8 @@ export const demoSpec: Spec = { { id: 1, title: "Learn JSON Render", completed: true }, { id: 2, - title: "Try @json-render/vue or @json-render/react", + title: + "Try @json-render/vue, @json-render/react, and @json-render/svelte", completed: false, }, { id: 3, title: "Build something awesome", completed: false }, @@ -47,6 +48,7 @@ export const demoSpec: Spec = { on: { pressVue: { action: "switchToVue" }, pressReact: { action: "switchToReact" }, + pressSvelte: { action: "switchToSvelte" }, }, }, diff --git a/examples/vite-renderers/src/svelte/App.svelte b/examples/vite-renderers/src/svelte/App.svelte new file mode 100644 index 00000000..05e7b075 --- /dev/null +++ b/examples/vite-renderers/src/svelte/App.svelte @@ -0,0 +1,22 @@ + + +
+ + + +
diff --git a/examples/vite-renderers/src/svelte/DemoRenderer.svelte b/examples/vite-renderers/src/svelte/DemoRenderer.svelte new file mode 100644 index 00000000..32006ae4 --- /dev/null +++ b/examples/vite-renderers/src/svelte/DemoRenderer.svelte @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/examples/vite-renderers/src/svelte/catalog.ts b/examples/vite-renderers/src/svelte/catalog.ts new file mode 100644 index 00000000..4ab7b8a9 --- /dev/null +++ b/examples/vite-renderers/src/svelte/catalog.ts @@ -0,0 +1,4 @@ +import { schema } from "@json-render/svelte/schema"; +import { catalogDef } from "../shared/catalog-def"; + +export const catalog = schema.createCatalog(catalogDef); diff --git a/examples/vite-renderers/src/svelte/components/Badge.svelte b/examples/vite-renderers/src/svelte/components/Badge.svelte new file mode 100644 index 00000000..c449f7dc --- /dev/null +++ b/examples/vite-renderers/src/svelte/components/Badge.svelte @@ -0,0 +1,25 @@ + + + + {_props.label} + diff --git a/examples/vite-renderers/src/svelte/components/Button.svelte b/examples/vite-renderers/src/svelte/components/Button.svelte new file mode 100644 index 00000000..3be13cf4 --- /dev/null +++ b/examples/vite-renderers/src/svelte/components/Button.svelte @@ -0,0 +1,45 @@ + + + diff --git a/examples/vite-renderers/src/svelte/components/Card.svelte b/examples/vite-renderers/src/svelte/components/Card.svelte new file mode 100644 index 00000000..d705f79a --- /dev/null +++ b/examples/vite-renderers/src/svelte/components/Card.svelte @@ -0,0 +1,48 @@ + + +
+ {#if _props.title} +

+ {_props.title} +

+ {/if} + {#if _props.subtitle} +

+ {_props.subtitle} +

+ {/if} + {#if children} + {@render children()} + {/if} +
diff --git a/examples/vite-renderers/src/svelte/components/Input.svelte b/examples/vite-renderers/src/svelte/components/Input.svelte new file mode 100644 index 00000000..af52e736 --- /dev/null +++ b/examples/vite-renderers/src/svelte/components/Input.svelte @@ -0,0 +1,29 @@ + + + diff --git a/examples/vite-renderers/src/svelte/components/ListItem.svelte b/examples/vite-renderers/src/svelte/components/ListItem.svelte new file mode 100644 index 00000000..e38f4076 --- /dev/null +++ b/examples/vite-renderers/src/svelte/components/ListItem.svelte @@ -0,0 +1,50 @@ + + + diff --git a/examples/vite-renderers/src/svelte/components/RendererBadge.svelte b/examples/vite-renderers/src/svelte/components/RendererBadge.svelte new file mode 100644 index 00000000..4f64e079 --- /dev/null +++ b/examples/vite-renderers/src/svelte/components/RendererBadge.svelte @@ -0,0 +1,17 @@ + + + + + {_props.renderer === "vue" + ? "Rendered with Vue" + : _props.renderer === "react" + ? "Rendered with React" + : "Rendered with Svelte"} + diff --git a/examples/vite-renderers/src/svelte/components/RendererTabs.svelte b/examples/vite-renderers/src/svelte/components/RendererTabs.svelte new file mode 100644 index 00000000..c1e61365 --- /dev/null +++ b/examples/vite-renderers/src/svelte/components/RendererTabs.svelte @@ -0,0 +1,47 @@ + + +
+ Render +
+ + + +
+
diff --git a/examples/vite-renderers/src/svelte/components/Stack.svelte b/examples/vite-renderers/src/svelte/components/Stack.svelte new file mode 100644 index 00000000..9ac37ad2 --- /dev/null +++ b/examples/vite-renderers/src/svelte/components/Stack.svelte @@ -0,0 +1,36 @@ + + +
+ {#if children} + {@render children()} + {/if} +
diff --git a/examples/vite-renderers/src/svelte/components/Text.svelte b/examples/vite-renderers/src/svelte/components/Text.svelte new file mode 100644 index 00000000..ca19695d --- /dev/null +++ b/examples/vite-renderers/src/svelte/components/Text.svelte @@ -0,0 +1,35 @@ + + + + {String(_props.content ?? "")} + diff --git a/examples/vite-renderers/src/svelte/mount.ts b/examples/vite-renderers/src/svelte/mount.ts new file mode 100644 index 00000000..bf0d4fea --- /dev/null +++ b/examples/vite-renderers/src/svelte/mount.ts @@ -0,0 +1,19 @@ +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; + } +} diff --git a/examples/vite-renderers/src/svelte/registry.ts b/examples/vite-renderers/src/svelte/registry.ts new file mode 100644 index 00000000..95291b8e --- /dev/null +++ b/examples/vite-renderers/src/svelte/registry.ts @@ -0,0 +1,28 @@ +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, +}); diff --git a/examples/vite-renderers/src/vue/registry.ts b/examples/vite-renderers/src/vue/registry.ts index 6bbf6f2d..1368e878 100644 --- a/examples/vite-renderers/src/vue/registry.ts +++ b/examples/vite-renderers/src/vue/registry.ts @@ -128,7 +128,11 @@ export const components: Components = { RendererBadge: ({ props }) => h("span", { class: "json-render-renderer-badge" }, [ h("span", { class: "json-render-renderer-dot" }), - props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React", + props.renderer === "vue" + ? "Rendered with Vue" + : props.renderer === "react" + ? "Rendered with React" + : "Rendered with Svelte", ]), RendererTabs: ({ props, emit }) => @@ -161,6 +165,19 @@ export const components: Components = { }, "React", ), + h( + "button", + { + onClick: () => emit("pressSvelte"), + class: [ + "json-render-renderer-tab", + props.renderer === "svelte" && "json-render-renderer-tab--active", + ] + .filter(Boolean) + .join(" "), + }, + "Svelte", + ), ]), ]), }; diff --git a/examples/vite-renderers/svelte.config.js b/examples/vite-renderers/svelte.config.js new file mode 100644 index 00000000..ff8b4c56 --- /dev/null +++ b/examples/vite-renderers/svelte.config.js @@ -0,0 +1 @@ +export default {}; diff --git a/examples/vite-renderers/tsconfig.json b/examples/vite-renderers/tsconfig.json index cfe58fa9..f78d80c1 100644 --- a/examples/vite-renderers/tsconfig.json +++ b/examples/vite-renderers/tsconfig.json @@ -13,5 +13,5 @@ "jsx": "react-jsx", "strict": true }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.svelte"] } diff --git a/examples/vite-renderers/vite.config.ts b/examples/vite-renderers/vite.config.ts index f4825774..4ef87262 100644 --- a/examples/vite-renderers/vite.config.ts +++ b/examples/vite-renderers/vite.config.ts @@ -1,7 +1,8 @@ import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import react from "@vitejs/plugin-react"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; export default defineConfig({ - plugins: [vue(), react({ include: /\.tsx$/ })], + plugins: [svelte(), vue(), react({ include: /\.tsx$/ })], }); diff --git a/examples/vue/package.json b/examples/vue/package.json index aa864378..0fb2035f 100644 --- a/examples/vue/package.json +++ b/examples/vue/package.json @@ -11,7 +11,7 @@ "@json-render/core": "workspace:*", "@json-render/vue": "workspace:*", "vue": "^3.5.0", - "zod": "^4.3.6" + "zod": "4.3.5" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.4", diff --git a/examples/vue/src/DemoRenderer.vue b/examples/vue/src/DemoRenderer.vue index 04358f89..d211b7e3 100644 --- a/examples/vue/src/DemoRenderer.vue +++ b/examples/vue/src/DemoRenderer.vue @@ -49,6 +49,7 @@ const handlers = { }> ).slice(); const item = todos[index]; + console.log("item", item); if (item) { todos[index] = { ...item, completed: !item.completed }; } diff --git a/package.json b/package.json index 160b4556..615840ca 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ }, "devDependencies": { "@changesets/cli": "2.29.8", - "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@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", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 89315201..615a9544 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -59,7 +59,7 @@ "devDependencies": { "@internal/typescript-config": "workspace:*", "@sveltejs/package": "^2.3.0", - "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@sveltejs/vite-plugin-svelte": "^6.2.4", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "^5.4.5" diff --git a/packages/svelte/src/contexts/RepeatScopeProvider.svelte b/packages/svelte/src/contexts/RepeatScopeProvider.svelte index c2063ccc..da156c72 100644 --- a/packages/svelte/src/contexts/RepeatScopeProvider.svelte +++ b/packages/svelte/src/contexts/RepeatScopeProvider.svelte @@ -23,7 +23,7 @@ } - @@ -34,10 +33,9 @@ Svelte 5 renderer that converts JSON specs into Svelte component trees. Uses run ```typescript import { defineCatalog } from "@json-render/core"; -import { schema, defineRegistry } from "@json-render/svelte"; +import { schema } from "@json-render/svelte"; import { z } from "zod"; -// Create catalog with props schemas export const catalog = defineCatalog(schema, { components: { Button: { @@ -57,15 +55,15 @@ export const catalog = defineCatalog(schema, { ## Defining Components -Components must be `.svelte` files that accept `ComponentRenderProps`: +Components should accept `ComponentRenderProps`: ```typescript interface ComponentRenderProps { element: UIElement; // The element with resolved props + children?: Snippet; // Child elements (use {@render children()}) + emit: (event: string) => void; // Fire a named event bindings?: Record; // Map of prop names to state paths (for $bindState) loading?: boolean; // True while spec is streaming - emit: (event: string) => void; // Fire a named event - children?: Snippet; // Child elements (use {@render children()}) } ``` @@ -75,8 +73,7 @@ interface ComponentRenderProps { import type { ComponentRenderProps } from "@json-render/svelte"; interface Props extends ComponentRenderProps<{ label: string; variant?: string }> {} - - let { element, emit, bindings, loading }: Props = $props(); + let { element, emit }: Props = $props(); {#if stream.spec} - + {/if} ``` -## Key Exports - -| Export | Purpose | -| ---------------------- | ---------------------------------------------------- | -| `defineRegistry` | Create a type-safe component registry from a catalog | -| `Renderer` | Render a spec using a registry | -| `JsonUIProvider` | Provide all contexts to the component tree | -| `schema` | Element tree schema | -| `getStateValue` | Read/write state via `.current` (preferred) | -| `getBoundProp` | Read/write bound prop via `.current` (preferred) | -| `isVisible` | Evaluate visibility via `.current` (preferred) | -| `getAction` | Read action handler via `.current` (preferred) | -| `getStateContext` | Access full state context (advanced) | -| `getActionContext` | Access full actions context (advanced) | -| `getVisibilityContext` | Access full visibility context (advanced) | -| `getValidationContext` | Access validation context | -| `createUIStream` | Stream specs from an API endpoint | -| `createChatUI` | Chat interface with integrated UI generation | +Use `createChatUI` for chat + UI responses: + +```typescript +const chat = createChatUI({ api: "/api/chat-ui" }); +await chat.send("Build a settings panel"); +``` From bd1bfb58eefd4fa094d6a4ca753534d93742c6ac Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 26 Feb 2026 21:01:30 +0100 Subject: [PATCH 08/15] correct component props for catalog, harmonize defineRegistry API with other APis --- apps/web/app/(main)/docs/api/svelte/page.mdx | 6 +- .../lib/render/components/Accordion.svelte | 10 +-- .../src/lib/render/components/Alert.svelte | 14 ++-- .../src/lib/render/components/Badge.svelte | 8 +-- .../src/lib/render/components/BarChart.svelte | 20 +++--- .../src/lib/render/components/Button.svelte | 14 ++-- .../src/lib/render/components/Callout.svelte | 20 +++--- .../src/lib/render/components/Card.svelte | 16 ++--- .../src/lib/render/components/Grid.svelte | 10 +-- .../src/lib/render/components/Heading.svelte | 16 ++--- .../lib/render/components/LineChart.svelte | 18 ++--- .../src/lib/render/components/Link.svelte | 10 +-- .../src/lib/render/components/Metric.svelte | 24 +++---- .../src/lib/render/components/PieChart.svelte | 22 +++--- .../src/lib/render/components/Progress.svelte | 8 +-- .../lib/render/components/RadioGroup.svelte | 14 ++-- .../lib/render/components/SelectInput.svelte | 18 ++--- .../lib/render/components/Separator.svelte | 4 +- .../src/lib/render/components/Skeleton.svelte | 8 +-- .../src/lib/render/components/Stack.svelte | 12 ++-- .../lib/render/components/TabContent.svelte | 8 +-- .../src/lib/render/components/Table.svelte | 16 ++--- .../src/lib/render/components/Tabs.svelte | 10 +-- .../src/lib/render/components/Text.svelte | 10 +-- .../lib/render/components/TextInput.svelte | 16 ++--- .../src/lib/render/components/Timeline.svelte | 8 +-- examples/svelte/package.json | 2 +- .../svelte/src/lib/components/Badge.svelte | 15 ++-- .../svelte/src/lib/components/Button.svelte | 23 +++--- .../svelte/src/lib/components/Card.svelte | 15 ++-- .../svelte/src/lib/components/Input.svelte | 10 +-- .../svelte/src/lib/components/ListItem.svelte | 23 +++--- .../svelte/src/lib/components/Stack.svelte | 17 +++-- .../svelte/src/lib/components/Text.svelte | 15 ++-- .../src/svelte/components/Badge.svelte | 15 ++-- .../src/svelte/components/Button.svelte | 23 +++--- .../src/svelte/components/Card.svelte | 15 ++-- .../src/svelte/components/Input.svelte | 10 +-- .../src/svelte/components/ListItem.svelte | 23 +++--- .../svelte/components/RendererBadge.svelte | 11 ++- .../src/svelte/components/RendererTabs.svelte | 13 ++-- .../src/svelte/components/Stack.svelte | 17 +++-- .../src/svelte/components/Text.svelte | 15 ++-- packages/svelte/src/Renderer.svelte | 17 +++-- packages/svelte/src/TestButton.svelte | 13 ++-- packages/svelte/src/TestContainer.svelte | 12 ++-- packages/svelte/src/TestText.svelte | 9 ++- packages/svelte/src/catalog-types.ts | 18 +++-- packages/svelte/src/index.ts | 17 +++-- packages/svelte/src/registry.ts | 71 +++++++++++-------- packages/svelte/src/renderer.test.ts | 15 ++-- packages/svelte/src/types.ts | 44 +----------- skills/json-render-svelte/SKILL.md | 40 +++++------ 53 files changed, 411 insertions(+), 447 deletions(-) diff --git a/apps/web/app/(main)/docs/api/svelte/page.mdx b/apps/web/app/(main)/docs/api/svelte/page.mdx index 5463e8ba..a082a7bb 100644 --- a/apps/web/app/(main)/docs/api/svelte/page.mdx +++ b/apps/web/app/(main)/docs/api/svelte/page.mdx @@ -65,11 +65,11 @@ const { registry, handlers, executeAction } = defineRegistry(catalog, { ## Component Props -Registry components receive `ComponentRenderProps`: +Registry components receive `BaseComponentProps`: ```typescript -interface ComponentRenderProps { - element: UIElement; +interface BaseComponentProps { + props: TProps; children?: Snippet; emit: (event: string) => void; bindings?: Record; diff --git a/examples/svelte-chat/src/lib/render/components/Accordion.svelte b/examples/svelte-chat/src/lib/render/components/Accordion.svelte index 9bcd6790..18773536 100644 --- a/examples/svelte-chat/src/lib/render/components/Accordion.svelte +++ b/examples/svelte-chat/src/lib/render/components/Accordion.svelte @@ -1,17 +1,17 @@ - - {#each element.props.items ?? [] as item, i} + + {#each props.items ?? [] as item, i} {item.title} diff --git a/examples/svelte-chat/src/lib/render/components/Alert.svelte b/examples/svelte-chat/src/lib/render/components/Alert.svelte index f3bf8466..a216747a 100644 --- a/examples/svelte-chat/src/lib/render/components/Alert.svelte +++ b/examples/svelte-chat/src/lib/render/components/Alert.svelte @@ -1,19 +1,19 @@ - - {element.props.title} - {#if element.props.description} - {element.props.description} + + {props.title} + {#if props.description} + {props.description} {/if} diff --git a/examples/svelte-chat/src/lib/render/components/Badge.svelte b/examples/svelte-chat/src/lib/render/components/Badge.svelte index 03088a54..9ee24c46 100644 --- a/examples/svelte-chat/src/lib/render/components/Badge.svelte +++ b/examples/svelte-chat/src/lib/render/components/Badge.svelte @@ -1,13 +1,13 @@ -{element.props.text} +{props.text} diff --git a/examples/svelte-chat/src/lib/render/components/BarChart.svelte b/examples/svelte-chat/src/lib/render/components/BarChart.svelte index a8238c48..0b110a90 100644 --- a/examples/svelte-chat/src/lib/render/components/BarChart.svelte +++ b/examples/svelte-chat/src/lib/render/components/BarChart.svelte @@ -1,7 +1,7 @@
- {#if element.props.title} -

{element.props.title}

+ {#if props.title} +

{props.title}

{/if} {#if chartData.items.length === 0}
No data available
{:else} -
+
{#each chartData.items as item}
{item.label} diff --git a/examples/svelte-chat/src/lib/render/components/Button.svelte b/examples/svelte-chat/src/lib/render/components/Button.svelte index 2fc4a6a7..011bc2ee 100644 --- a/examples/svelte-chat/src/lib/render/components/Button.svelte +++ b/examples/svelte-chat/src/lib/render/components/Button.svelte @@ -1,22 +1,22 @@ diff --git a/examples/svelte-chat/src/lib/render/components/Callout.svelte b/examples/svelte-chat/src/lib/render/components/Callout.svelte index 78343e31..f5da6ad9 100644 --- a/examples/svelte-chat/src/lib/render/components/Callout.svelte +++ b/examples/svelte-chat/src/lib/render/components/Callout.svelte @@ -1,14 +1,14 @@
- {#if element.props.type === "tip"} + {#if props.type === "tip"} - {:else if element.props.type === "warning"} + {:else if props.type === "warning"} - {:else if element.props.type === "important"} + {:else if props.type === "important"} {:else} {/if}
- {#if element.props.title} -

{element.props.title}

+ {#if props.title} +

{props.title}

{/if} -

{element.props.content}

+

{props.content}

diff --git a/examples/svelte-chat/src/lib/render/components/Card.svelte b/examples/svelte-chat/src/lib/render/components/Card.svelte index 5d826d30..b6e6751e 100644 --- a/examples/svelte-chat/src/lib/render/components/Card.svelte +++ b/examples/svelte-chat/src/lib/render/components/Card.svelte @@ -1,26 +1,26 @@ - {#if element.props.title || element.props.description} + {#if props.title || props.description} - {#if element.props.title} - {element.props.title} + {#if props.title} + {props.title} {/if} - {#if element.props.description} - {element.props.description} + {#if props.description} + {props.description} {/if} {/if} diff --git a/examples/svelte-chat/src/lib/render/components/Grid.svelte b/examples/svelte-chat/src/lib/render/components/Grid.svelte index 5cfd947a..d1a95063 100644 --- a/examples/svelte-chat/src/lib/render/components/Grid.svelte +++ b/examples/svelte-chat/src/lib/render/components/Grid.svelte @@ -1,15 +1,15 @@ diff --git a/examples/svelte-chat/src/lib/render/components/Heading.svelte b/examples/svelte-chat/src/lib/render/components/Heading.svelte index a40fb9bc..01dbd420 100644 --- a/examples/svelte-chat/src/lib/render/components/Heading.svelte +++ b/examples/svelte-chat/src/lib/render/components/Heading.svelte @@ -1,22 +1,22 @@ {#if level === "h1"} -

{element.props.text}

+

{props.text}

{:else if level === "h2"} -

{element.props.text}

+

{props.text}

{:else if level === "h3"} -

{element.props.text}

+

{props.text}

{:else} -

{element.props.text}

+

{props.text}

{/if} diff --git a/examples/svelte-chat/src/lib/render/components/LineChart.svelte b/examples/svelte-chat/src/lib/render/components/LineChart.svelte index 8a19244c..feb84675 100644 --- a/examples/svelte-chat/src/lib/render/components/LineChart.svelte +++ b/examples/svelte-chat/src/lib/render/components/LineChart.svelte @@ -1,7 +1,7 @@
- {#if element.props.title} -

{element.props.title}

+ {#if props.title} +

{props.title}

{/if} {#if chartData.length === 0} diff --git a/examples/svelte-chat/src/lib/render/components/Link.svelte b/examples/svelte-chat/src/lib/render/components/Link.svelte index a05126a8..e8de97cb 100644 --- a/examples/svelte-chat/src/lib/render/components/Link.svelte +++ b/examples/svelte-chat/src/lib/render/components/Link.svelte @@ -1,19 +1,19 @@ - {element.props.text} + {props.text} diff --git a/examples/svelte-chat/src/lib/render/components/Metric.svelte b/examples/svelte-chat/src/lib/render/components/Metric.svelte index ee6296e4..1a27d1ac 100644 --- a/examples/svelte-chat/src/lib/render/components/Metric.svelte +++ b/examples/svelte-chat/src/lib/render/components/Metric.svelte @@ -1,40 +1,40 @@
-

{element.props.label}

+

{props.label}

- {element.props.value} - {#if element.props.trend} - {#if element.props.trend === "up"} + {props.value} + {#if props.trend} + {#if props.trend === "up"} - {:else if element.props.trend === "down"} + {:else if props.trend === "down"} {:else} {/if} {/if}
- {#if element.props.detail} -

{element.props.detail}

+ {#if props.detail} +

{props.detail}

{/if}
diff --git a/examples/svelte-chat/src/lib/render/components/PieChart.svelte b/examples/svelte-chat/src/lib/render/components/PieChart.svelte index 53bc294c..ff2295a2 100644 --- a/examples/svelte-chat/src/lib/render/components/PieChart.svelte +++ b/examples/svelte-chat/src/lib/render/components/PieChart.svelte @@ -1,7 +1,7 @@
- {#if element.props.title} -

{element.props.title}

+ {#if props.title} +

{props.title}

{/if} {#if items.length === 0}
No data available
{:else} -
+
{#each segments() as seg} {#if seg.endAngle - seg.startAngle >= 1} diff --git a/examples/svelte-chat/src/lib/render/components/Progress.svelte b/examples/svelte-chat/src/lib/render/components/Progress.svelte index b4cbf875..b74fed29 100644 --- a/examples/svelte-chat/src/lib/render/components/Progress.svelte +++ b/examples/svelte-chat/src/lib/render/components/Progress.svelte @@ -1,13 +1,13 @@ - + diff --git a/examples/svelte-chat/src/lib/render/components/RadioGroup.svelte b/examples/svelte-chat/src/lib/render/components/RadioGroup.svelte index b1ec73c0..b9d4e8f5 100644 --- a/examples/svelte-chat/src/lib/render/components/RadioGroup.svelte +++ b/examples/svelte-chat/src/lib/render/components/RadioGroup.svelte @@ -1,20 +1,20 @@
- {#if element.props.label} - + {#if props.label} + {/if} - {#each element.props.options ?? [] as opt} + {#each props.options ?? [] as opt}