Compare commits

...
Author SHA1 Message Date
Chris Tate dc38be8efe fixes 2026-02-25 00:59:21 -06:00
Chris Tate aa7eef1aea vue 2026-02-25 00:04:12 -06:00
34 changed files with 10769 additions and 46 deletions
+2
View File
@@ -23,6 +23,8 @@ coverage
# Build Outputs
.next/
.nuxt/
.output/
out/
build
dist
+5
View File
@@ -0,0 +1,5 @@
<template>
<div id="app">
<NuxtPage />
</div>
</template>
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+399
View File
@@ -0,0 +1,399 @@
import type { Spec } from "@json-render/core";
export interface Example {
name: string;
description: string;
spec: Spec;
}
export const examples: Example[] = [
{
name: "User Profile Card",
description: "A card with user info, badges, and a progress bar",
spec: {
root: "card",
elements: {
card: {
type: "Card",
props: {
title: "User Profile",
description: null,
maxWidth: "md",
centered: null,
},
children: ["stack"],
},
stack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: ["heading", "text", "badges", "sep", "progress"],
},
heading: {
type: "Heading",
props: { text: "Jane Cooper", level: "h2" },
},
text: {
type: "Text",
props: {
text: "Senior software engineer based in San Francisco. Passionate about building accessible, high-performance web applications.",
variant: "muted",
},
},
badges: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: null,
},
children: ["b1", "b2", "b3"],
},
b1: {
type: "Badge",
props: { text: "TypeScript", variant: "default" },
},
b2: { type: "Badge", props: { text: "Vue", variant: "secondary" } },
b3: { type: "Badge", props: { text: "Node.js", variant: "outline" } },
sep: { type: "Separator", props: { orientation: null } },
progress: {
type: "Progress",
props: { value: 72, max: 100, label: "Profile completion" },
},
},
},
},
{
name: "Settings Form",
description: "A form with inputs, selects, and switches",
spec: {
root: "card",
state: {
name: "Ada Lovelace",
email: "ada@example.com",
role: "Engineer",
notifications: true,
darkMode: false,
},
elements: {
card: {
type: "Card",
props: {
title: "Account Settings",
description: "Manage your preferences",
maxWidth: "md",
centered: null,
},
children: ["form"],
},
form: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"nameInput",
"emailInput",
"roleSelect",
"sep",
"notifSwitch",
"darkSwitch",
"sep2",
"actions",
],
},
nameInput: {
type: "Input",
props: {
label: "Full Name",
name: "name",
type: "text",
placeholder: "Your name",
value: { $bindState: "/name" },
checks: null,
},
},
emailInput: {
type: "Input",
props: {
label: "Email",
name: "email",
type: "email",
placeholder: "you@example.com",
value: { $bindState: "/email" },
checks: null,
},
},
roleSelect: {
type: "Select",
props: {
label: "Role",
name: "role",
options: [
"Engineer",
"Designer",
"Product Manager",
"Data Scientist",
],
placeholder: "Choose a role",
value: { $bindState: "/role" },
checks: null,
},
},
sep: { type: "Separator", props: { orientation: null } },
notifSwitch: {
type: "Switch",
props: {
label: "Email notifications",
name: "notifications",
checked: { $bindState: "/notifications" },
},
},
darkSwitch: {
type: "Switch",
props: {
label: "Dark mode",
name: "darkMode",
checked: { $bindState: "/darkMode" },
},
},
sep2: { type: "Separator", props: { orientation: null } },
actions: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: "end",
},
children: ["cancelBtn", "saveBtn"],
},
cancelBtn: {
type: "Button",
props: { label: "Cancel", variant: "secondary", disabled: null },
},
saveBtn: {
type: "Button",
props: { label: "Save Changes", variant: "primary", disabled: null },
},
},
},
},
{
name: "Pricing Table",
description: "A grid of pricing cards with feature lists",
spec: {
root: "outer",
elements: {
outer: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: ["header", "grid"],
},
header: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: "center",
justify: null,
},
children: ["title", "subtitle"],
},
title: {
type: "Heading",
props: { text: "Simple, transparent pricing", level: "h1" },
},
subtitle: {
type: "Text",
props: {
text: "Choose the plan that fits your needs. Upgrade or downgrade at any time.",
variant: "muted",
},
},
grid: {
type: "Grid",
props: { columns: 3, gap: "md" },
children: ["free", "pro", "enterprise"],
},
free: {
type: "Card",
props: { title: "Free", description: "$0/month" },
children: ["freeContent"],
},
freeContent: {
type: "Stack",
props: { direction: "vertical", gap: "sm" },
children: ["f1", "f2", "f3", "freeBtn"],
},
f1: {
type: "Text",
props: { text: "Up to 3 projects", variant: "body" },
},
f2: { type: "Text", props: { text: "1 GB storage", variant: "body" } },
f3: {
type: "Text",
props: { text: "Community support", variant: "body" },
},
freeBtn: {
type: "Button",
props: { label: "Get Started", variant: "secondary" },
},
pro: {
type: "Card",
props: { title: "Pro", description: "$19/month" },
children: ["proContent"],
},
proContent: {
type: "Stack",
props: { direction: "vertical", gap: "sm" },
children: ["p1", "p2", "p3", "p4", "proBtn"],
},
p1: {
type: "Text",
props: { text: "Unlimited projects", variant: "body" },
},
p2: { type: "Text", props: { text: "50 GB storage", variant: "body" } },
p3: {
type: "Text",
props: { text: "Priority support", variant: "body" },
},
p4: {
type: "Text",
props: { text: "Custom domains", variant: "body" },
},
proBtn: {
type: "Button",
props: { label: "Upgrade to Pro", variant: "primary" },
},
enterprise: {
type: "Card",
props: { title: "Enterprise", description: "Custom pricing" },
children: ["entContent"],
},
entContent: {
type: "Stack",
props: { direction: "vertical", gap: "sm" },
children: ["e1", "e2", "e3", "e4", "entBtn"],
},
e1: {
type: "Text",
props: { text: "Everything in Pro", variant: "body" },
},
e2: {
type: "Text",
props: { text: "Unlimited storage", variant: "body" },
},
e3: {
type: "Text",
props: { text: "Dedicated support", variant: "body" },
},
e4: {
type: "Text",
props: { text: "SLA guarantees", variant: "body" },
},
entBtn: {
type: "Button",
props: { label: "Contact Sales", variant: "secondary" },
},
},
},
},
{
name: "Status Dashboard",
description: "System status with alerts, a table, and an accordion",
spec: {
root: "root",
elements: {
root: {
type: "Stack",
props: { direction: "vertical", gap: "lg" },
children: [
"heading",
"alertOk",
"alertWarn",
"sep",
"table",
"sep2",
"accordion",
],
},
heading: {
type: "Heading",
props: { text: "System Status", level: "h1" },
},
alertOk: {
type: "Alert",
props: {
title: "API",
message: "All systems operational -- 99.98% uptime this month",
type: "success",
},
},
alertWarn: {
type: "Alert",
props: {
title: "Database",
message: "Elevated latency detected in us-east-1 region",
type: "warning",
},
},
sep: { type: "Separator", props: { orientation: null } },
table: {
type: "Table",
props: {
columns: ["Service", "Status", "Latency", "Uptime"],
rows: [
["API Gateway", "Operational", "12ms", "99.99%"],
["Auth Service", "Operational", "8ms", "99.98%"],
["Database", "Degraded", "145ms", "99.85%"],
["CDN", "Operational", "3ms", "100%"],
["Workers", "Operational", "22ms", "99.97%"],
],
caption: "Last updated 2 minutes ago",
},
},
sep2: { type: "Separator", props: { orientation: null } },
accordion: {
type: "Accordion",
props: {
items: [
{
title: "What does 'Degraded' mean?",
content:
"The service is still operational but experiencing slower response times than normal. We are actively investigating.",
},
{
title: "How is uptime calculated?",
content:
"Uptime is measured over the current calendar month based on successful health-check pings every 30 seconds.",
},
{
title: "How do I subscribe to updates?",
content:
"You can subscribe to incident notifications via email or webhook on the Settings page.",
},
],
type: "single",
},
},
},
},
},
];
+173
View File
@@ -0,0 +1,173 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/vue/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string().optional(),
description: z.string().nullable().optional(),
maxWidth: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
centered: z.boolean().nullable().optional(),
}),
slots: ["default"],
description: "A card container with optional title and description",
},
Stack: {
props: z.object({
direction: z.enum(["horizontal", "vertical"]).optional(),
gap: z.enum(["sm", "md", "lg"]).optional(),
align: z
.enum(["start", "center", "end", "stretch"])
.nullable()
.optional(),
justify: z
.enum(["start", "center", "end", "between", "around"])
.nullable()
.optional(),
}),
slots: ["default"],
description:
"Flex container for laying out children horizontally or vertically",
},
Grid: {
props: z.object({
columns: z.number().optional(),
gap: z.enum(["sm", "md", "lg"]).optional(),
}),
slots: ["default"],
description: "Grid layout container",
},
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).optional(),
}),
slots: [],
description: "Heading text element",
},
Text: {
props: z.object({
text: z.string(),
variant: z.enum(["body", "muted", "bold"]).optional(),
}),
slots: [],
description: "Text paragraph element",
},
Button: {
props: z.object({
label: z.string(),
variant: z
.enum(["primary", "secondary", "outline", "danger"])
.optional(),
disabled: z.boolean().nullable().optional(),
}),
slots: [],
description: "Clickable button. Emits 'press' event.",
},
Input: {
props: z.object({
label: z.string().optional(),
name: z.string(),
type: z.enum(["text", "email", "password", "number"]).optional(),
placeholder: z.string().optional(),
value: z.string().optional(),
checks: z.any().nullable().optional(),
validateOn: z.string().nullable().optional(),
}),
slots: [],
description:
"Text input field with optional label. Emits 'change' event.",
},
Select: {
props: z.object({
label: z.string().optional(),
name: z.string(),
options: z.array(z.string()),
placeholder: z.string().optional(),
value: z.string().optional(),
checks: z.any().nullable().optional(),
validateOn: z.string().nullable().optional(),
}),
slots: [],
description: "Dropdown select field. Emits 'change' event.",
},
Switch: {
props: z.object({
label: z.string(),
name: z.string(),
checked: z.boolean().optional(),
}),
slots: [],
description: "Toggle switch. Emits 'change' event.",
},
Badge: {
props: z.object({
text: z.string(),
variant: z
.enum(["default", "secondary", "outline", "destructive"])
.optional(),
}),
slots: [],
description: "Small status badge label",
},
Separator: {
props: z.object({
orientation: z.enum(["horizontal", "vertical"]).nullable().optional(),
}),
slots: [],
description: "Visual separator line",
},
Progress: {
props: z.object({
value: z.number(),
max: z.number().optional(),
label: z.string().optional(),
}),
slots: [],
description: "Progress bar",
},
Alert: {
props: z.object({
title: z.string(),
message: z.string(),
type: z.enum(["info", "success", "warning", "error"]).optional(),
}),
slots: [],
description: "Alert / notification banner",
},
Table: {
props: z.object({
columns: z.array(z.string()),
rows: z.array(z.array(z.string())),
caption: z.string().optional(),
}),
slots: [],
description: "Data table with columns and rows",
},
Accordion: {
props: z.object({
items: z.array(z.object({ title: z.string(), content: z.string() })),
type: z.enum(["single", "multiple"]).optional(),
}),
slots: [],
description: "Collapsible accordion sections",
},
Radio: {
props: z.object({
label: z.string().optional(),
name: z.string(),
options: z.array(z.string()),
value: z.string().optional(),
checks: z.any().nullable().optional(),
validateOn: z.string().nullable().optional(),
}),
slots: [],
description: "Radio button group. Emits 'change' event.",
},
},
actions: {},
// Cast needed: zod schema types don't align with the inferred catalog input
// type from defineCatalog's generics when no actions are defined.
} as any);
+593
View File
@@ -0,0 +1,593 @@
import { h, ref, defineComponent, type VNode, type PropType } from "vue";
import {
defineRegistry,
useBoundProp,
useFieldValidation,
type BaseComponentProps,
} from "@json-render/vue";
import { catalog } from "./catalog";
type Ctx<P = Record<string, unknown>> = BaseComponentProps<P>;
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({
props,
children,
}: Ctx<{
title?: string;
description?: string | null;
maxWidth?: "sm" | "md" | "lg" | "xl" | null;
centered?: boolean | null;
}>) => {
const maxWidthClass =
props.maxWidth === "sm"
? "max-w-sm"
: props.maxWidth === "md"
? "max-w-md"
: props.maxWidth === "lg"
? "max-w-lg"
: props.maxWidth === "xl"
? "max-w-xl"
: "";
return h(
"div",
{
class: [
"rounded-lg border bg-white shadow-sm p-6",
maxWidthClass,
props.centered ? "mx-auto" : "",
]
.filter(Boolean)
.join(" "),
},
[
props.title &&
h("h3", { class: "text-lg font-semibold" }, props.title),
props.description &&
h("p", { class: "text-sm text-gray-500 mt-1" }, props.description),
(props.title || props.description) &&
h("div", { class: "mt-4" }, children),
!(props.title || props.description) && children,
].filter(Boolean) as VNode[],
);
},
Stack: ({
props,
children,
}: Ctx<{
direction?: "horizontal" | "vertical";
gap?: "sm" | "md" | "lg";
align?: "start" | "center" | "end" | "stretch" | null;
justify?: "start" | "center" | "end" | "between" | "around" | null;
}>) => {
const gapClass =
props.gap === "sm" ? "gap-2" : props.gap === "lg" ? "gap-6" : "gap-4";
const dirClass =
props.direction === "horizontal" ? "flex-row" : "flex-col";
const alignMap: Record<string, string> = {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch",
};
const justifyMap: Record<string, string> = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
between: "justify-between",
around: "justify-around",
};
const alignClass = props.align ? (alignMap[props.align] ?? "") : "";
const justifyClass = props.justify
? (justifyMap[props.justify] ?? "")
: "";
return h(
"div",
{
class: ["flex", dirClass, gapClass, alignClass, justifyClass]
.filter(Boolean)
.join(" "),
},
children,
);
},
Grid: ({
props,
children,
}: Ctx<{ columns?: number; gap?: "sm" | "md" | "lg" }>) => {
const cols = props.columns ?? 3;
const gapClass =
props.gap === "sm" ? "gap-2" : props.gap === "lg" ? "gap-6" : "gap-4";
const colClass =
cols === 1
? "grid-cols-1"
: cols === 2
? "grid-cols-2"
: cols === 3
? "grid-cols-3"
: cols === 4
? "grid-cols-4"
: `grid-cols-${cols}`;
return h("div", { class: `grid ${colClass} ${gapClass}` }, children);
},
Heading: ({
props,
}: Ctx<{ text: string; level?: "h1" | "h2" | "h3" | "h4" }>) => {
const tag = props.level ?? "h2";
const sizeClass =
tag === "h1"
? "text-3xl font-bold"
: tag === "h2"
? "text-2xl font-semibold"
: tag === "h3"
? "text-xl font-semibold"
: "text-lg font-medium";
return h(tag, { class: sizeClass }, props.text);
},
Text: ({
props,
}: Ctx<{ text: string; variant?: "body" | "muted" | "bold" }>) => {
const variantClass =
props.variant === "muted"
? "text-gray-500"
: props.variant === "bold"
? "font-semibold"
: "";
return h("p", { class: variantClass }, props.text);
},
Button: ({
props,
emit,
}: Ctx<{
label: string;
variant?: "primary" | "secondary" | "outline" | "danger";
disabled?: boolean | null;
}>) => {
const baseClass =
"inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none";
const variantClass =
props.variant === "primary"
? "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500"
: props.variant === "danger"
? "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500"
: props.variant === "outline"
? "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500";
return h(
"button",
{
class: `${baseClass} ${variantClass}`,
disabled: props.disabled ?? false,
onClick: () => emit("press"),
},
props.label,
);
},
Input: ({
props,
bindings,
emit,
}: Ctx<{
label?: string;
name: string;
type?: string;
placeholder?: string;
value?: string;
checks?: unknown;
validateOn?: string | null;
}>) => {
const [value, setValue] = useBoundProp<string>(
props.value,
bindings?.value,
);
const validation = props.checks
? useFieldValidation(`/${props.name}`, {
checks: props.checks as any[],
validateOn: (props.validateOn as "blur" | "change") ?? undefined,
})
: null;
return h(
"div",
{ class: "flex flex-col gap-1.5" },
[
props.label &&
h(
"label",
{ class: "text-sm font-medium text-gray-700" },
props.label,
),
h("input", {
type: props.type ?? "text",
placeholder: props.placeholder,
value: value ?? "",
class:
"rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500",
onInput: (e: Event) => {
const val = (e.target as HTMLInputElement).value;
setValue(val);
emit("change");
if (validation && props.validateOn === "change") {
validation.validate();
}
},
onBlur: () => {
if (validation && props.validateOn === "blur") {
validation.touch();
validation.validate();
}
},
}),
validation &&
validation.errors.value.length > 0 &&
h(
"p",
{ class: "text-xs text-red-500" },
validation.errors.value[0],
),
].filter(Boolean) as VNode[],
);
},
Select: ({
props,
bindings,
emit,
}: Ctx<{
label?: string;
name: string;
options: string[];
placeholder?: string;
value?: string;
checks?: unknown;
validateOn?: string | null;
}>) => {
const [value, setValue] = useBoundProp<string>(
props.value,
bindings?.value,
);
const options = props.options ?? [];
const validation = props.checks
? useFieldValidation(`/${props.name}`, {
checks: props.checks as any[],
validateOn: (props.validateOn as "blur" | "change") ?? undefined,
})
: null;
return h(
"div",
{ class: "flex flex-col gap-1.5" },
[
props.label &&
h(
"label",
{ class: "text-sm font-medium text-gray-700" },
props.label,
),
h(
"select",
{
value: value ?? "",
class:
"rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500",
onChange: (e: Event) => {
const val = (e.target as HTMLSelectElement).value;
setValue(val);
emit("change");
if (validation && props.validateOn === "change") {
validation.validate();
}
},
},
[
props.placeholder &&
h("option", { value: "", disabled: true }, props.placeholder),
...options.map((opt: string) =>
h("option", { value: opt, key: opt }, opt),
),
].filter(Boolean) as VNode[],
),
validation &&
validation.errors.value.length > 0 &&
h(
"p",
{ class: "text-xs text-red-500" },
validation.errors.value[0],
),
].filter(Boolean) as VNode[],
);
},
Switch: ({
props,
bindings,
emit,
}: Ctx<{ label: string; name: string; checked?: boolean }>) => {
const [checked, setChecked] = useBoundProp<boolean>(
props.checked,
bindings?.checked,
);
const isOn = checked ?? false;
return h("label", { class: "flex items-center gap-3 cursor-pointer" }, [
h(
"button",
{
role: "switch",
"aria-checked": isOn,
class: [
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
isOn ? "bg-blue-600" : "bg-gray-200",
].join(" "),
onClick: () => {
setChecked(!isOn);
emit("change");
},
},
h("span", {
class: [
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
isOn ? "translate-x-6" : "translate-x-1",
].join(" "),
}),
),
h("span", { class: "text-sm" }, props.label),
]);
},
Badge: ({
props,
}: Ctx<{
text: string;
variant?: "default" | "secondary" | "outline" | "destructive";
}>) => {
const variantClass =
props.variant === "secondary"
? "bg-gray-100 text-gray-800"
: props.variant === "outline"
? "border border-gray-300 text-gray-700"
: props.variant === "destructive"
? "bg-red-100 text-red-800"
: "bg-blue-100 text-blue-800";
return h(
"span",
{
class: `inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${variantClass}`,
},
props.text,
);
},
Separator: () => {
return h("hr", { class: "border-gray-200" });
},
Progress: ({
props,
}: Ctx<{ value: number; max?: number; label?: string }>) => {
const pct = Math.min(
100,
Math.round((props.value / (props.max ?? 100)) * 100),
);
return h(
"div",
{ class: "flex flex-col gap-1.5" },
[
props.label &&
h("div", { class: "flex justify-between text-sm" }, [
h("span", { class: "text-gray-600" }, props.label),
h("span", { class: "font-medium" }, `${pct}%`),
]),
h(
"div",
{
class: "h-2 w-full rounded-full bg-gray-200 overflow-hidden",
},
h("div", {
class: "h-full rounded-full bg-blue-600 transition-all",
style: { width: `${pct}%` },
}),
),
].filter(Boolean) as VNode[],
);
},
Alert: ({
props,
}: Ctx<{
title: string;
message: string;
type?: "info" | "success" | "warning" | "error";
}>) => {
const typeClasses: Record<string, string> = {
success: "bg-green-50 border-green-200 text-green-800",
warning: "bg-yellow-50 border-yellow-200 text-yellow-800",
error: "bg-red-50 border-red-200 text-red-800",
info: "bg-blue-50 border-blue-200 text-blue-800",
};
const cls = typeClasses[props.type ?? "info"] ?? typeClasses.info;
return h("div", { class: `rounded-md border p-4 ${cls}` }, [
h("p", { class: "text-sm font-medium" }, props.title),
h("p", { class: "text-sm mt-1 opacity-80" }, props.message),
]);
},
Table: ({
props,
}: Ctx<{
columns: string[];
rows: string[][];
caption?: string;
}>) => {
return h(
"div",
{ class: "overflow-x-auto" },
[
h("table", { class: "min-w-full text-sm" }, [
h(
"thead",
null,
h(
"tr",
{ class: "border-b" },
props.columns.map((col: string) =>
h(
"th",
{
key: col,
class: "px-4 py-2 text-left font-medium text-gray-500",
},
col,
),
),
),
),
h(
"tbody",
null,
props.rows.map((row: string[], i: number) =>
h(
"tr",
{ key: i, class: "border-b last:border-0" },
row.map((cell: string, j: number) =>
h("td", { key: j, class: "px-4 py-2 text-gray-700" }, cell),
),
),
),
),
]),
props.caption &&
h("p", { class: "text-xs text-gray-400 mt-2 px-4" }, props.caption),
].filter(Boolean) as VNode[],
);
},
// Uses defineComponent instead of a plain render function because it
// needs local reactive state (openItems ref) to track expanded items.
Accordion: defineComponent({
props: {
props: {
type: Object as PropType<{
items: Array<{ title: string; content: string }>;
type?: "single" | "multiple";
}>,
required: true,
},
},
setup(compProps) {
const openItems = ref<Set<number>>(new Set());
const toggle = (idx: number) => {
const next = new Set(openItems.value);
if (next.has(idx)) {
next.delete(idx);
} else {
if (compProps.props.type === "single") next.clear();
next.add(idx);
}
openItems.value = next;
};
return () =>
h(
"div",
{ class: "divide-y border rounded-md" },
compProps.props.items.map(
(item: { title: string; content: string }, i: number) =>
h("div", { key: i }, [
h(
"button",
{
class:
"flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-left hover:bg-gray-50 transition-colors",
onClick: () => toggle(i),
},
[
h("span", null, item.title),
h(
"span",
{
class: [
"transform transition-transform text-gray-400",
openItems.value.has(i) ? "rotate-180" : "",
].join(" "),
},
"\u25BE",
),
],
),
openItems.value.has(i) &&
h(
"div",
{ class: "px-4 pb-3 text-sm text-gray-600" },
item.content,
),
]),
),
);
},
}) as any,
Radio: ({
props,
bindings,
emit,
}: Ctx<{
label?: string;
name: string;
options: string[];
value?: string;
}>) => {
const [value, setValue] = useBoundProp<string>(
props.value,
bindings?.value,
);
return h(
"div",
{ class: "flex flex-col gap-1.5" },
[
props.label &&
h(
"label",
{ class: "text-sm font-medium text-gray-700" },
props.label,
),
h(
"div",
{ class: "flex gap-4" },
(props.options ?? []).map((opt: string) =>
h(
"label",
{
key: opt,
class: "flex items-center gap-2 cursor-pointer",
},
[
h("input", {
type: "radio",
name: props.name,
value: opt,
checked: value === opt,
class: "accent-blue-600",
onChange: () => {
setValue(opt);
emit("change");
},
}),
h("span", { class: "text-sm" }, opt),
],
),
),
),
].filter(Boolean) as VNode[],
);
},
},
});
+10
View File
@@ -0,0 +1,10 @@
import tailwindcss from "@tailwindcss/vite";
export default defineNuxtConfig({
compatibilityDate: "2025-11-01",
css: ["~/assets/css/main.css"],
vite: {
// Tailwind's Vite plugin types don't align with Nuxt's plugin types
plugins: [tailwindcss() as any],
},
});
+25
View File
@@ -0,0 +1,25 @@
{
"name": "example-nuxt",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "portless nuxt-demo.json-render nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview",
"check-types": "nuxt typecheck"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/vue": "workspace:*",
"nuxt": "^4.3.1",
"vue": "^3.5.29",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"tailwindcss": "^4.2.1",
"typescript": "^5.7.2"
}
}
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { JSONUIProvider, Renderer } from "@json-render/vue";
import { registry } from "~/lib/render/registry";
import { examples } from "~/lib/examples";
const selectedIndex = ref(0);
const selected = computed(() => examples[selectedIndex.value]!);
</script>
<template>
<div class="min-h-screen flex flex-col bg-gray-50">
<!-- Example selector -->
<nav class="flex gap-1 p-3 overflow-x-auto border-b bg-white shrink-0">
<button
v-for="(ex, i) in examples"
:key="ex.name"
class="px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors"
:class="
i === selectedIndex
? 'bg-blue-600 text-white'
: 'hover:bg-gray-100 text-gray-700'
"
@click="selectedIndex = i"
>
{{ ex.name }}
</button>
</nav>
<!-- Render area -->
<div class="flex-1 flex items-start justify-center overflow-auto p-6">
<div
class="bg-white border rounded-lg shadow-sm w-full max-w-[960px] p-6"
>
<p class="text-xs text-gray-400 mb-4">
{{ selected.description }}
</p>
<JSONUIProvider
:key="selectedIndex"
:registry="registry"
:initial-state="selected.spec.state ?? {}"
>
<Renderer :spec="selected.spec" :registry="registry" />
</JSONUIProvider>
</div>
</div>
</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
+2
View File
@@ -30,6 +30,7 @@
"@changesets/cli": "2.29.8",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@testing-library/vue": "^8.1.0",
"@types/react": "^19.2.3",
"husky": "^9.1.7",
"jsdom": "^27.4.0",
@@ -39,6 +40,7 @@
"react-dom": "^19.2.4",
"turbo": "^2.7.4",
"typescript": "5.9.2",
"vue": "^3.5.29",
"vitest": "^4.0.17"
},
"packageManager": "pnpm@10.29.3",
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "vue"
}
}
+275
View File
@@ -0,0 +1,275 @@
# @json-render/vue
Vue 3 renderer for json-render. Turn JSON specs into Vue components with data binding, visibility, and actions.
## Installation
```bash
npm install @json-render/vue @json-render/core zod
```
## Quick Start
### 1. Create a Catalog
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/vue/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
}),
description: "A card container",
},
Button: {
props: z.object({
label: z.string(),
}),
description: "A clickable button",
},
Input: {
props: z.object({
value: z.union([z.string(), z.record(z.unknown())]).nullable(),
label: z.string(),
placeholder: z.string().nullable(),
}),
description: "Text input field with optional value binding",
},
},
actions: {
submit: { description: "Submit the form" },
},
});
```
### 2. Define Component Implementations
```typescript
import { h } from "vue";
import { defineRegistry, useBoundProp } from "@json-render/vue";
import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [
h("h3", null, props.title),
props.description && h("p", null, props.description),
children,
]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp(props.value, bindings?.value);
return h("label", null, [
props.label,
h("input", {
placeholder: props.placeholder ?? "",
value: value ?? "",
onInput: (e: Event) =>
setValue((e.target as HTMLInputElement).value),
}),
]);
},
},
actions: {
submit: async (_params, _setState) => {
console.log("Form submitted");
},
},
});
```
### 3. Render a Spec
```vue
<script setup lang="ts">
import { JSONUIProvider, Renderer } from "@json-render/vue";
import { registry } from "./registry";
const spec = {
root: "card",
state: { name: "" },
elements: {
card: {
type: "Card",
props: { title: "Hello", description: null },
children: ["input", "btn"],
},
input: {
type: "Input",
props: {
label: "Name",
placeholder: "Enter your name",
value: { $bindState: "/name" },
},
},
btn: {
type: "Button",
props: { label: "Submit" },
on: { press: { action: "submit" } },
},
},
};
</script>
<template>
<JSONUIProvider :registry="registry" :initial-state="spec.state ?? {}">
<Renderer :spec="spec" :registry="registry" />
</JSONUIProvider>
</template>
```
## Spec Format
The spec is a flat map of elements. Each element references its children by key:
```json
{
"root": "card",
"state": { "count": 0 },
"elements": {
"card": {
"type": "Card",
"props": { "title": "Counter" },
"children": ["display", "increment"]
},
"display": {
"type": "Text",
"props": { "text": { "$state": "/count" } }
},
"increment": {
"type": "Button",
"props": { "label": "+1" },
"on": {
"press": {
"action": "setState",
"params": { "statePath": "/count", "value": 1 }
}
}
}
}
}
```
## Composables
### `useStateStore()`
Access the state context inside any component rendered within a `JSONUIProvider`.
```typescript
import { useStateStore } from "@json-render/vue";
const { state, get, set, getSnapshot } = useStateStore();
```
### `useBoundProp(propValue, bindingPath)`
Two-way binding helper for `$bindState` / `$bindItem` expressions.
```typescript
import { useBoundProp } from "@json-render/vue";
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
```
### `useVisibility()`
Evaluate visibility conditions.
```typescript
import { useVisibility } from "@json-render/vue";
const { isVisible, ctx } = useVisibility();
```
### `useActions()`
Access action execution.
```typescript
import { useActions } from "@json-render/vue";
const { execute, loadingActions } = useActions();
```
### `useFieldValidation(path, config)`
Field-level validation.
```typescript
import { useFieldValidation } from "@json-render/vue";
const { errors, isValid, validate, touch } = useFieldValidation("/email", {
checks: [{ type: "required", message: "Email is required" }],
});
```
## Provider Components
All providers are renderless components that use Vue's `provide`/`inject`:
- **`StateProvider`** -- Manages state model with `createStateStore` from core
- **`VisibilityProvider`** -- Evaluates visibility conditions against state
- **`ValidationProvider`** -- Handles form validation
- **`ActionProvider`** -- Executes action bindings (setState, pushState, custom actions)
- **`JSONUIProvider`** -- Composes all of the above into a single provider
## Dynamic Prop Expressions
Props support dynamic expressions that resolve against state:
| Expression | Description |
|---|---|
| `{ "$state": "/path" }` | Read a value from state |
| `{ "$bindState": "/path" }` | Two-way binding to state |
| `{ "$item": "field" }` | Read from current repeat item |
| `{ "$bindItem": "field" }` | Two-way bind to repeat item field |
| `{ "$index": true }` | Current repeat index |
| `{ "$cond": ..., "$then": ..., "$else": ... }` | Conditional value |
| `{ "$computed": "fnName", "args": {...} }` | Computed function |
| `{ "$template": "Hello ${/name}" }` | String interpolation |
## Built-in Actions
| Action | Description |
|---|---|
| `setState` | Set a value at a state path |
| `pushState` | Append to an array in state |
| `removeState` | Remove an item from an array by index |
| `validateForm` | Validate all registered fields |
## Key Exports
```typescript
// Renderer
export { Renderer, JSONUIProvider, defineRegistry, createRenderer, useBoundProp };
// Composables
export { useStateStore, useStateValue };
export { useVisibility, useIsVisible };
export { useActions, useAction };
export { useValidation, useFieldValidation };
export { useRepeatScope };
// Provider components
export { StateProvider, VisibilityProvider, ActionProvider, ValidationProvider };
export { RepeatScopeProvider };
// Schema
export { schema };
// Types
export type {
ComponentRenderProps, ComponentRenderer, ComponentRegistry,
RendererProps, JSONUIProviderProps, CreateRendererProps,
BaseComponentProps, ComponentContext, ComponentFn,
EventHandle, SetState, StateModel,
};
```
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@json-render/vue",
"version": "0.1.0",
"license": "Apache-2.0",
"description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./schema": {
"types": "./dist/schema.d.ts",
"import": "./dist/schema.js",
"require": "./dist/schema.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"tsup": "^8.5.1",
"typescript": "^5.7.2",
"vue": "^3.5.29"
},
"peerDependencies": {
"vue": "^3.5.0"
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { VNode } from "vue";
import type {
Catalog,
InferCatalogComponents,
InferCatalogActions,
InferComponentProps,
InferActionParams,
StateModel,
} from "@json-render/core";
export type { StateModel };
export type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
export interface EventHandle {
emit: () => void;
shouldPreventDefault: boolean;
bound: boolean;
}
export interface BaseComponentProps<P = Record<string, unknown>> {
props: P;
children?: VNode[];
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
loading?: boolean;
}
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> extends BaseComponentProps<InferComponentProps<C, K>> {}
export type ComponentFn<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> = (ctx: ComponentContext<C, K>) => VNode | VNode[] | null;
export type Components<C extends Catalog> = {
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
};
export type ActionFn<
C extends Catalog,
K extends keyof InferCatalogActions<C>,
> = (
params: InferActionParams<C, K> | undefined,
setState: SetState,
state: StateModel,
) => Promise<void>;
export type Actions<C extends Catalog> = {
[K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
};
export type CatalogHasActions<C extends Catalog> = [
InferCatalogActions<C>,
] extends [never]
? false
: [keyof InferCatalogActions<C>] extends [never]
? false
: true;
@@ -0,0 +1,216 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, createApp, nextTick } from "vue";
import { ActionProvider, useActions } from "./actions.js";
import { StateProvider, useStateStore } from "./state.js";
import { VisibilityProvider } from "./visibility.js";
import { ValidationProvider } from "./validation.js";
function runInProviders<T>(
initialState: Record<string, unknown>,
fn: () => T,
handlers?: Record<string, (...args: unknown[]) => void>,
): T {
let result: T | undefined;
const Inner = defineComponent({
setup() {
result = fn();
return () => null;
},
});
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState }, () =>
h(VisibilityProvider, null, () =>
h(ValidationProvider, null, () =>
h(ActionProvider, { handlers: handlers ?? {} }, () => h(Inner)),
),
),
);
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
return result!;
}
describe("useActions", () => {
it("provides execute function", () => {
const ctx = runInProviders({}, () => useActions());
expect(typeof ctx.execute).toBe("function");
});
it("provides confirm and cancel functions", () => {
const ctx = runInProviders({}, () => useActions());
expect(typeof ctx.confirm).toBe("function");
expect(typeof ctx.cancel).toBe("function");
});
});
describe("action execution", () => {
it("executes setState action", async () => {
let storeCtx: ReturnType<typeof useStateStore> | undefined;
let actionsCtx: ReturnType<typeof useActions> | undefined;
const Inner = defineComponent({
setup() {
storeCtx = useStateStore();
actionsCtx = useActions();
return () => null;
},
});
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState: { count: 0 } }, () =>
h(VisibilityProvider, null, () =>
h(ValidationProvider, null, () =>
h(ActionProvider, { handlers: {} }, () => h(Inner)),
),
),
);
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
await actionsCtx!.execute({
action: "setState",
params: { statePath: "/count", value: 42 },
});
expect(storeCtx!.get("/count")).toBe(42);
app.unmount();
});
it("executes pushState action", async () => {
let storeCtx: ReturnType<typeof useStateStore> | undefined;
let actionsCtx: ReturnType<typeof useActions> | undefined;
const Inner = defineComponent({
setup() {
storeCtx = useStateStore();
actionsCtx = useActions();
return () => null;
},
});
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState: { items: ["a"] } }, () =>
h(VisibilityProvider, null, () =>
h(ValidationProvider, null, () =>
h(ActionProvider, { handlers: {} }, () => h(Inner)),
),
),
);
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
await actionsCtx!.execute({
action: "pushState",
params: { statePath: "/items", value: "b" },
});
expect(storeCtx!.get("/items")).toEqual(["a", "b"]);
app.unmount();
});
it("executes removeState action", async () => {
let storeCtx: ReturnType<typeof useStateStore> | undefined;
let actionsCtx: ReturnType<typeof useActions> | undefined;
const Inner = defineComponent({
setup() {
storeCtx = useStateStore();
actionsCtx = useActions();
return () => null;
},
});
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState: { items: ["a", "b", "c"] } }, () =>
h(VisibilityProvider, null, () =>
h(ValidationProvider, null, () =>
h(ActionProvider, { handlers: {} }, () => h(Inner)),
),
),
);
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
await actionsCtx!.execute({
action: "removeState",
params: { statePath: "/items", index: 1 },
});
expect(storeCtx!.get("/items")).toEqual(["a", "c"]);
app.unmount();
});
it("warns when no handler is registered for custom action", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const actionsCtx = runInProviders({}, () => useActions());
// The provider is unmounted, but execute still runs synchronously for the warn path
// We need a mounted tree for this test
let actions: ReturnType<typeof useActions> | undefined;
const Inner = defineComponent({
setup() {
actions = useActions();
return () => null;
},
});
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState: {} }, () =>
h(VisibilityProvider, null, () =>
h(ValidationProvider, null, () =>
h(ActionProvider, { handlers: {} }, () => h(Inner)),
),
),
);
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
await actions!.execute({ action: "unknownAction" });
expect(warnSpy).toHaveBeenCalledWith(
"No handler registered for action: unknownAction",
);
warnSpy.mockRestore();
app.unmount();
});
});
+456
View File
@@ -0,0 +1,456 @@
import {
defineComponent,
h,
provide,
inject,
ref,
type InjectionKey,
type PropType,
} from "vue";
import {
resolveAction,
executeAction,
type ActionBinding,
type ActionHandler,
type ActionConfirm,
type ResolvedAction,
} from "@json-render/core";
import { useStateStore } from "./state.js";
import { useOptionalValidation } from "./validation.js";
function generateUniqueId(): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
const MAX_RESOLVE_DEPTH = 20;
function deepResolveValue(
value: unknown,
get: (path: string) => unknown,
depth: number = 0,
): unknown {
if (depth > MAX_RESOLVE_DEPTH) return value;
if (value === null || value === undefined) return value;
if (value === "$id") {
return generateUniqueId();
}
if (typeof value === "object" && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 1 && typeof obj.$state === "string") {
return get(obj.$state as string);
}
if (keys.length === 1 && "$id" in obj) {
return generateUniqueId();
}
}
if (Array.isArray(value)) {
return value.map((item) => deepResolveValue(item, get, depth + 1));
}
if (typeof value === "object") {
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
resolved[key] = deepResolveValue(val, get, depth + 1);
}
return resolved;
}
return value;
}
export interface PendingConfirmation {
action: ResolvedAction;
handler: ActionHandler;
resolve: () => void;
reject: () => void;
}
export interface ActionContextValue {
handlers: Record<string, ActionHandler>;
loadingActions: Set<string>;
pendingConfirmation: PendingConfirmation | null;
execute: (binding: ActionBinding) => Promise<void>;
confirm: () => void;
cancel: () => void;
registerHandler: (name: string, handler: ActionHandler) => void;
}
export const ActionKey: InjectionKey<ActionContextValue> = Symbol(
"json-render-actions",
);
export interface ActionProviderProps {
handlers?: Record<string, ActionHandler>;
navigate?: (path: string) => void;
}
export const ActionProvider = defineComponent({
name: "ActionProvider",
props: {
handlers: {
type: Object as PropType<Record<string, ActionHandler>>,
default: () => ({}),
},
navigate: {
type: Function as PropType<(path: string) => void>,
default: undefined,
},
},
setup(props, { slots }) {
const storeCtx = useStateStore();
const validation = useOptionalValidation();
const handlersRef = ref<Record<string, ActionHandler>>({
...props.handlers,
});
const loadingActionsRef = ref<Set<string>>(new Set());
const pendingConfirmationRef = ref<PendingConfirmation | null>(null);
const registerHandler = (name: string, handler: ActionHandler) => {
handlersRef.value = { ...handlersRef.value, [name]: handler };
};
const execute = async (binding: ActionBinding) => {
const resolved = resolveAction(binding, storeCtx.getSnapshot());
if (resolved.action === "setState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const value = resolved.params.value;
if (statePath) {
storeCtx.set(statePath, value);
}
return;
}
if (resolved.action === "pushState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const rawValue = resolved.params.value;
if (statePath) {
const resolvedValue = deepResolveValue(rawValue, storeCtx.get);
const arr = (storeCtx.get(statePath) as unknown[] | undefined) ?? [];
storeCtx.set(statePath, [...arr, resolvedValue]);
const clearStatePath = resolved.params.clearStatePath as
| string
| undefined;
if (clearStatePath) {
storeCtx.set(clearStatePath, "");
}
}
return;
}
if (resolved.action === "removeState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const index = resolved.params.index as number;
if (statePath !== undefined && index !== undefined) {
const arr = (storeCtx.get(statePath) as unknown[] | undefined) ?? [];
storeCtx.set(
statePath,
arr.filter((_, i) => i !== index),
);
}
return;
}
if (resolved.action === "push" && resolved.params) {
const screen = resolved.params.screen as string;
if (screen) {
const currentScreen = storeCtx.get("/currentScreen") as
| string
| undefined;
const navStack =
(storeCtx.get("/navStack") as string[] | undefined) ?? [];
if (currentScreen) {
storeCtx.set("/navStack", [...navStack, currentScreen]);
} else {
storeCtx.set("/navStack", [...navStack, ""]);
}
storeCtx.set("/currentScreen", screen);
}
return;
}
if (resolved.action === "pop") {
const navStack =
(storeCtx.get("/navStack") as string[] | undefined) ?? [];
if (navStack.length > 0) {
const previousScreen = navStack[navStack.length - 1];
storeCtx.set("/navStack", navStack.slice(0, -1));
if (previousScreen) {
storeCtx.set("/currentScreen", previousScreen);
} else {
storeCtx.set("/currentScreen", undefined);
}
}
return;
}
if (resolved.action === "validateForm") {
const validateAll = validation?.validateAll;
if (!validateAll) {
console.warn(
"validateForm action was dispatched but no ValidationProvider is connected.",
);
return;
}
const valid = validateAll();
const statePath =
(resolved.params?.statePath as string) || "/formValidation";
storeCtx.set(statePath, { valid });
return;
}
const handler = handlersRef.value[resolved.action];
if (!handler) {
console.warn(`No handler registered for action: ${resolved.action}`);
return;
}
if (resolved.confirm) {
return new Promise<void>((resolve, reject) => {
pendingConfirmationRef.value = {
action: resolved,
handler,
resolve: () => {
pendingConfirmationRef.value = null;
resolve();
},
reject: () => {
pendingConfirmationRef.value = null;
reject(new Error("Action cancelled"));
},
};
}).then(async () => {
const next = new Set(loadingActionsRef.value);
next.add(resolved.action);
loadingActionsRef.value = next;
try {
await executeAction({
action: resolved,
handler,
setState: storeCtx.set,
navigate: props.navigate,
executeAction: async (name) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
const fin = new Set(loadingActionsRef.value);
fin.delete(resolved.action);
loadingActionsRef.value = fin;
}
});
} else {
const next = new Set(loadingActionsRef.value);
next.add(resolved.action);
loadingActionsRef.value = next;
try {
await executeAction({
action: resolved,
handler,
setState: storeCtx.set,
navigate: props.navigate,
executeAction: async (name) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
const fin = new Set(loadingActionsRef.value);
fin.delete(resolved.action);
loadingActionsRef.value = fin;
}
}
};
const confirm = () => {
pendingConfirmationRef.value?.resolve();
};
const cancel = () => {
pendingConfirmationRef.value?.reject();
};
const ctx: ActionContextValue = {
get handlers() {
return handlersRef.value;
},
get loadingActions() {
return loadingActionsRef.value;
},
get pendingConfirmation() {
return pendingConfirmationRef.value;
},
execute,
confirm,
cancel,
registerHandler,
};
provide(ActionKey, ctx);
return () => slots.default?.();
},
});
export function useActions(): ActionContextValue {
const ctx = inject(ActionKey);
if (!ctx) {
throw new Error("useActions must be used within an ActionProvider");
}
return ctx;
}
export function useAction(binding: ActionBinding): {
execute: () => Promise<void>;
isLoading: boolean;
} {
const { execute, loadingActions } = useActions();
return {
execute: () => execute(binding),
get isLoading() {
return loadingActions.has(binding.action);
},
};
}
// ---------------------------------------------------------------------------
// ConfirmDialog
// ---------------------------------------------------------------------------
export interface ConfirmDialogProps {
confirm: {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: string;
};
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmDialog = defineComponent({
name: "ConfirmDialog",
props: {
confirm: {
type: Object as PropType<ConfirmDialogProps["confirm"]>,
required: true,
},
onConfirm: { type: Function as PropType<() => void>, required: true },
onCancel: { type: Function as PropType<() => void>, required: true },
},
setup(props) {
return () => {
const isDanger = props.confirm.variant === "danger";
return h(
"div",
{
style: {
position: "fixed",
inset: "0",
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 50,
},
onClick: props.onCancel,
},
[
h(
"div",
{
style: {
backgroundColor: "white",
borderRadius: "8px",
padding: "24px",
maxWidth: "400px",
width: "100%",
boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.1)",
},
onClick: (e: Event) => e.stopPropagation(),
},
[
h(
"h3",
{
style: {
margin: "0 0 8px 0",
fontSize: "18px",
fontWeight: "600",
},
},
props.confirm.title,
),
h(
"p",
{
style: { margin: "0 0 24px 0", color: "#6b7280" },
},
props.confirm.message,
),
h(
"div",
{
style: {
display: "flex",
gap: "12px",
justifyContent: "flex-end",
},
},
[
h(
"button",
{
onClick: props.onCancel,
style: {
padding: "8px 16px",
borderRadius: "6px",
border: "1px solid #d1d5db",
backgroundColor: "white",
cursor: "pointer",
},
},
props.confirm.cancelLabel ?? "Cancel",
),
h(
"button",
{
onClick: props.onConfirm,
style: {
padding: "8px 16px",
borderRadius: "6px",
border: "none",
backgroundColor: isDanger ? "#dc2626" : "#3b82f6",
color: "white",
cursor: "pointer",
},
},
props.confirm.confirmLabel ?? "Confirm",
),
],
),
],
),
],
);
};
},
});
@@ -0,0 +1,56 @@
import {
defineComponent,
provide,
inject,
type InjectionKey,
type PropType,
} from "vue";
export interface RepeatScopeValue {
item: unknown;
index: number;
basePath: string;
}
export const RepeatScopeKey: InjectionKey<RepeatScopeValue | null> = Symbol(
"json-render-repeat-scope",
);
export const RepeatScopeProvider = defineComponent({
name: "RepeatScopeProvider",
props: {
item: {
type: null as unknown as PropType<unknown>,
required: true,
},
index: {
type: Number,
required: true,
},
basePath: {
type: String,
required: true,
},
},
setup(props, { slots }) {
const value: RepeatScopeValue = {
get item() {
return props.item;
},
get index() {
return props.index;
},
get basePath() {
return props.basePath;
},
};
provide(RepeatScopeKey, value);
return () => slots.default?.();
},
});
export function useRepeatScope(): RepeatScopeValue | null {
return inject(RepeatScopeKey, null);
}
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { defineComponent, h, createApp, nextTick } from "vue";
import {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
} from "./state.js";
function runInStateProvider<T>(
initialState: Record<string, unknown>,
fn: () => T,
): T {
let result: T | undefined;
const Inner = defineComponent({
setup() {
result = fn();
return () => null;
},
});
const App = defineComponent({
setup() {
return () => h(StateProvider, { initialState }, () => h(Inner));
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
return result!;
}
describe("StateProvider + useStateStore", () => {
it("provides initial state and get/set round-trip", () => {
const ctx = runInStateProvider({ count: 0 }, () => useStateStore());
expect(ctx.get("/count")).toBe(0);
ctx.set("/count", 42);
expect(ctx.get("/count")).toBe(42);
});
it("state reflects initial values", () => {
const ctx = runInStateProvider({ name: "Alice" }, () => useStateStore());
expect(ctx.state.name).toBe("Alice");
});
it("getSnapshot returns current state", () => {
const ctx = runInStateProvider({ x: 1 }, () => useStateStore());
expect(ctx.getSnapshot().x).toBe(1);
ctx.set("/x", 99);
expect(ctx.getSnapshot().x).toBe(99);
});
it("update applies multiple changes", () => {
const ctx = runInStateProvider({ a: 1, b: 2 }, () => useStateStore());
ctx.update({ "/a": 10, "/b": 20 });
expect(ctx.get("/a")).toBe(10);
expect(ctx.get("/b")).toBe(20);
});
});
describe("useStateValue", () => {
it("reads a value from state", () => {
const value = runInStateProvider({ name: "Alice" }, () =>
useStateValue<string>("/name"),
);
expect(value).toBe("Alice");
});
it("returns undefined for missing path", () => {
const value = runInStateProvider({}, () =>
useStateValue<string>("/missing"),
);
expect(value).toBeUndefined();
});
});
describe("useStateBinding", () => {
it("returns value from state and a setter", () => {
const [value, setValue] = runInStateProvider({ x: 42 }, () =>
useStateBinding<number>("/x"),
);
expect(value).toBe(42);
expect(typeof setValue).toBe("function");
});
it("returns undefined for missing path", () => {
const [value] = runInStateProvider({}, () =>
useStateBinding<string>("/missing"),
);
expect(value).toBeUndefined();
});
});
+166
View File
@@ -0,0 +1,166 @@
import {
defineComponent,
provide,
inject,
shallowRef,
onBeforeUnmount,
watch,
type InjectionKey,
type PropType,
} from "vue";
import {
getByPath,
createStateStore,
type StateModel,
type StateStore,
} from "@json-render/core";
import { flattenToPointers } from "@json-render/core/store-utils";
export interface StateContextValue {
state: StateModel;
get: (path: string) => unknown;
set: (path: string, value: unknown) => void;
update: (updates: Record<string, unknown>) => void;
getSnapshot: () => StateModel;
}
export const StateKey: InjectionKey<StateContextValue> =
Symbol("json-render-state");
export interface StateProviderProps {
store?: StateStore;
initialState?: StateModel;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
}
export const StateProvider = defineComponent({
name: "StateProvider",
props: {
store: {
type: Object as PropType<StateStore>,
default: undefined,
},
initialState: {
type: Object as PropType<StateModel>,
default: () => ({}),
},
onStateChange: {
type: Function as PropType<
(changes: Array<{ path: string; value: unknown }>) => void
>,
default: undefined,
},
},
setup(props, { slots }) {
const isControlled = !!props.store;
let internalStore: StateStore | undefined;
if (!isControlled) {
internalStore = createStateStore(props.initialState);
}
const activeStore = props.store ?? internalStore!;
const stateRef = shallowRef<StateModel>(activeStore.getSnapshot());
const unsub = activeStore.subscribe(() => {
stateRef.value = activeStore.getSnapshot();
});
onBeforeUnmount(unsub);
// Sync initialState changes for uncontrolled mode
if (!isControlled) {
let prevFlat: Record<string, unknown> =
props.initialState && Object.keys(props.initialState).length > 0
? flattenToPointers(props.initialState)
: {};
watch(
() => props.initialState,
(newState) => {
if (isControlled || !newState) return;
const nextFlat =
Object.keys(newState).length > 0 ? flattenToPointers(newState) : {};
const allKeys = new Set([
...Object.keys(prevFlat),
...Object.keys(nextFlat),
]);
const updates: Record<string, unknown> = {};
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) {
activeStore.update(updates);
}
},
{ deep: true },
);
}
const get = (path: string) => activeStore.get(path);
const set = (path: string, value: unknown) => {
const prev = activeStore.getSnapshot();
activeStore.set(path, value);
if (!isControlled && activeStore.getSnapshot() !== prev) {
props.onStateChange?.([{ path, value }]);
}
};
const update = (updates: Record<string, unknown>) => {
const prev = activeStore.getSnapshot();
activeStore.update(updates);
if (!isControlled && activeStore.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) {
props.onStateChange?.(changes);
}
}
};
const getSnapshot = () => activeStore.getSnapshot();
const ctx: StateContextValue = {
get state() {
return stateRef.value;
},
get,
set,
update,
getSnapshot,
};
provide(StateKey, ctx);
return () => slots.default?.();
},
});
export function useStateStore(): StateContextValue {
const ctx = inject(StateKey);
if (!ctx) {
throw new Error("useStateStore must be used within a StateProvider");
}
return ctx;
}
export function useStateValue<T>(path: string): T | undefined {
const { state } = useStateStore();
return getByPath(state, path) as T | undefined;
}
export function useStateBinding<T>(
path: string,
): [T | undefined, (value: T) => void] {
const { state, set } = useStateStore();
const value = getByPath(state, path) as T | undefined;
const setValue = (newValue: T) => set(path, newValue);
return [value, setValue];
}
+191
View File
@@ -0,0 +1,191 @@
import {
defineComponent,
provide,
inject,
ref,
computed,
watchEffect,
type InjectionKey,
type PropType,
type ComputedRef,
} from "vue";
import {
runValidation,
type ValidationConfig,
type ValidationFunction,
type ValidationResult,
} from "@json-render/core";
import { useStateStore } from "./state.js";
export interface FieldValidationState {
touched: boolean;
validated: boolean;
result: ValidationResult | null;
}
export interface ValidationContextValue {
customFunctions: Record<string, ValidationFunction>;
fieldStates: Record<string, FieldValidationState>;
validate: (path: string, config: ValidationConfig) => ValidationResult;
touch: (path: string) => void;
clear: (path: string) => void;
validateAll: () => boolean;
registerField: (path: string, config: ValidationConfig) => void;
}
export const ValidationKey: InjectionKey<ValidationContextValue> = Symbol(
"json-render-validation",
);
export interface ValidationProviderProps {
customFunctions?: Record<string, ValidationFunction>;
}
export const ValidationProvider = defineComponent({
name: "ValidationProvider",
props: {
customFunctions: {
type: Object as PropType<Record<string, ValidationFunction>>,
default: () => ({}),
},
},
setup(props, { slots }) {
const storeCtx = useStateStore();
const fieldStates = ref<Record<string, FieldValidationState>>({});
const fieldConfigs = ref<Record<string, ValidationConfig>>({});
const registerField = (path: string, config: ValidationConfig) => {
fieldConfigs.value = { ...fieldConfigs.value, [path]: config };
};
const validate = (
path: string,
config: ValidationConfig,
): ValidationResult => {
const currentState = storeCtx.getSnapshot();
const segments = path.split("/").filter(Boolean);
let value: unknown = currentState;
for (const seg of segments) {
if (value != null && typeof value === "object") {
value = (value as Record<string, unknown>)[seg];
} else {
value = undefined;
break;
}
}
const result = runValidation(config, {
value,
stateModel: currentState,
customFunctions: props.customFunctions,
});
fieldStates.value = {
...fieldStates.value,
[path]: {
touched: fieldStates.value[path]?.touched ?? true,
validated: true,
result,
},
};
return result;
};
const touch = (path: string) => {
const existing = fieldStates.value[path];
fieldStates.value = {
...fieldStates.value,
[path]: {
touched: true,
validated: existing?.validated ?? false,
result: existing?.result ?? null,
},
};
};
const clear = (path: string) => {
const { [path]: _, ...rest } = fieldStates.value;
fieldStates.value = rest;
};
const validateAll = (): boolean => {
let allValid = true;
for (const [path, config] of Object.entries(fieldConfigs.value)) {
const result = validate(path, config);
if (!result.valid) {
allValid = false;
}
}
return allValid;
};
const ctx: ValidationContextValue = {
get customFunctions() {
return props.customFunctions;
},
get fieldStates() {
return fieldStates.value;
},
validate,
touch,
clear,
validateAll,
registerField,
};
provide(ValidationKey, ctx);
return () => slots.default?.();
},
});
export function useValidation(): ValidationContextValue {
const ctx = inject(ValidationKey);
if (!ctx) {
throw new Error("useValidation must be used within a ValidationProvider");
}
return ctx;
}
export function useOptionalValidation(): ValidationContextValue | null {
return inject(ValidationKey, null);
}
export function useFieldValidation(
path: string,
config?: ValidationConfig,
): {
state: ComputedRef<FieldValidationState>;
validate: () => ValidationResult;
touch: () => void;
clear: () => void;
errors: ComputedRef<string[]>;
isValid: ComputedRef<boolean>;
} {
const ctx = useValidation();
if (path && config) {
watchEffect(() => {
ctx.registerField(path, config);
});
}
const defaultState: FieldValidationState = {
touched: false,
validated: false,
result: null,
};
const state = computed(() => ctx.fieldStates[path] ?? defaultState);
const errors = computed(() => state.value.result?.errors ?? []);
const isValid = computed(() => state.value.result?.valid ?? true);
return {
state,
validate: () => ctx.validate(path, config ?? { checks: [] }),
touch: () => ctx.touch(path),
clear: () => ctx.clear(path),
errors,
isValid,
};
}
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { defineComponent, h, createApp } from "vue";
import {
VisibilityProvider,
useVisibility,
useIsVisible,
} from "./visibility.js";
import { StateProvider } from "./state.js";
function runInProviders<T>(
initialState: Record<string, unknown>,
fn: () => T,
): T {
let result: T | undefined;
const Inner = defineComponent({
setup() {
result = fn();
return () => null;
},
});
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState }, () =>
h(VisibilityProvider, null, () => h(Inner)),
);
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
return result!;
}
describe("useVisibility", () => {
it("provides isVisible function", () => {
const ctx = runInProviders({}, () => useVisibility());
expect(typeof ctx.isVisible).toBe("function");
});
it("provides visibility context with stateModel", () => {
const ctx = runInProviders({ test: true }, () => useVisibility());
expect(ctx.ctx.stateModel).toEqual({ test: true });
});
});
describe("useIsVisible", () => {
it("returns true for undefined condition", () => {
const result = runInProviders({}, () => useIsVisible(undefined));
expect(result).toBe(true);
});
it("returns true for true condition", () => {
const result = runInProviders({}, () => useIsVisible(true));
expect(result).toBe(true);
});
it("returns false for false condition", () => {
const result = runInProviders({}, () => useIsVisible(false));
expect(result).toBe(false);
});
it("evaluates $state conditions against state", () => {
const trueResult = runInProviders({ isVisible: true }, () =>
useIsVisible({ $state: "/isVisible" }),
);
expect(trueResult).toBe(true);
const falseResult = runInProviders({ isVisible: false }, () =>
useIsVisible({ $state: "/isVisible" }),
);
expect(falseResult).toBe(false);
});
it("evaluates equality conditions", () => {
const result = runInProviders({ count: 1 }, () =>
useIsVisible({ $state: "/count", eq: 1 }),
);
expect(result).toBe(true);
});
it("evaluates array conditions (implicit AND)", () => {
const result = runInProviders({ user: { isAdmin: true }, count: 5 }, () =>
useIsVisible([{ $state: "/user/isAdmin" }, { $state: "/count", eq: 5 }]),
);
expect(result).toBe(true);
});
});
@@ -0,0 +1,60 @@
import {
defineComponent,
provide,
inject,
computed,
type InjectionKey,
} from "vue";
import {
evaluateVisibility,
type VisibilityCondition,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
import { useStateStore } from "./state.js";
export interface VisibilityContextValue {
isVisible: (condition: VisibilityCondition | undefined) => boolean;
ctx: CoreVisibilityContext;
}
export const VisibilityKey: InjectionKey<VisibilityContextValue> = Symbol(
"json-render-visibility",
);
export const VisibilityProvider = defineComponent({
name: "VisibilityProvider",
setup(_, { slots }) {
const storeCtx = useStateStore();
const ctxRef = computed<CoreVisibilityContext>(() => ({
stateModel: storeCtx.state,
}));
const value: VisibilityContextValue = {
isVisible: (condition: VisibilityCondition | undefined) =>
evaluateVisibility(condition, ctxRef.value),
get ctx() {
return ctxRef.value;
},
};
provide(VisibilityKey, value);
return () => slots.default?.();
},
});
export function useVisibility(): VisibilityContextValue {
const ctx = inject(VisibilityKey);
if (!ctx) {
throw new Error("useVisibility must be used within a VisibilityProvider");
}
return ctx;
}
export function useIsVisible(
condition: VisibilityCondition | undefined,
): boolean {
const { isVisible } = useVisibility();
return isVisible(condition);
}
+394
View File
@@ -0,0 +1,394 @@
import { describe, it, expect } from "vitest";
import { defineComponent, h, createApp } from "vue";
import {
flatToTree,
buildSpecFromParts,
getTextFromParts,
useBoundProp,
} from "./hooks.js";
import { StateProvider, useStateBinding } from "./composables/state.js";
describe("flatToTree", () => {
it("converts array of elements to tree structure", () => {
const elements = [
{ key: "container", type: "stack", props: {}, parentKey: null },
{
key: "text1",
type: "text",
props: { content: "Hello" },
parentKey: "container",
},
{
key: "text2",
type: "text",
props: { content: "World" },
parentKey: "container",
},
];
const tree = flatToTree(elements);
expect(tree.root).toBe("container");
expect(Object.keys(tree.elements)).toHaveLength(3);
expect(tree.elements["container"]).toBeDefined();
expect(tree.elements["text1"]).toBeDefined();
expect(tree.elements["text2"]).toBeDefined();
});
it("builds parent-child relationships", () => {
const elements = [
{ key: "root", type: "stack", props: {}, parentKey: null },
{ key: "child1", type: "text", props: {}, parentKey: "root" },
{ key: "child2", type: "text", props: {}, parentKey: "root" },
];
const tree = flatToTree(elements);
expect(tree.elements["root"]!.children).toHaveLength(2);
expect(tree.elements["root"]!.children).toContain("child1");
expect(tree.elements["root"]!.children).toContain("child2");
});
it("handles single root element", () => {
const elements = [
{
key: "only",
type: "text",
props: { content: "Single" },
parentKey: null,
},
];
const tree = flatToTree(elements);
expect(tree.root).toBe("only");
expect(Object.keys(tree.elements)).toHaveLength(1);
});
it("handles deeply nested elements", () => {
const elements = [
{ key: "level0", type: "stack", props: {}, parentKey: null },
{ key: "level1", type: "stack", props: {}, parentKey: "level0" },
{ key: "level2", type: "stack", props: {}, parentKey: "level1" },
{ key: "level3", type: "text", props: {}, parentKey: "level2" },
];
const tree = flatToTree(elements);
expect(tree.root).toBe("level0");
expect(tree.elements["level0"]!.children).toContain("level1");
expect(tree.elements["level1"]!.children).toContain("level2");
expect(tree.elements["level2"]!.children).toContain("level3");
});
it("preserves element props", () => {
const elements = [
{
key: "btn",
type: "button",
props: { label: "Click me", variant: "primary" },
parentKey: null,
},
];
const tree = flatToTree(elements);
expect(tree.elements["btn"]!.props).toEqual({
label: "Click me",
variant: "primary",
});
});
it("preserves visibility conditions", () => {
const elements = [
{
key: "conditional",
type: "text",
props: {},
parentKey: null,
visible: { $state: "/isVisible" },
},
];
const tree = flatToTree(elements);
expect(tree.elements["conditional"]!.visible).toEqual({
$state: "/isVisible",
});
});
it("handles empty elements array", () => {
const tree = flatToTree([]);
expect(tree.root).toBe("");
expect(Object.keys(tree.elements)).toHaveLength(0);
});
it("handles multiple children correctly", () => {
const elements = [
{ key: "parent", type: "grid", props: {}, parentKey: null },
{ key: "a", type: "card", props: {}, parentKey: "parent" },
{ key: "b", type: "card", props: {}, parentKey: "parent" },
{ key: "c", type: "card", props: {}, parentKey: "parent" },
{ key: "d", type: "card", props: {}, parentKey: "parent" },
];
const tree = flatToTree(elements);
expect(tree.elements["parent"]!.children).toHaveLength(4);
expect(tree.elements["parent"]!.children).toEqual(["a", "b", "c", "d"]);
});
});
describe("buildSpecFromParts", () => {
it("returns null when no data-spec parts are present", () => {
const parts = [
{ type: "text", text: "Hello there" },
{ type: "text", text: "How can I help?" },
];
expect(buildSpecFromParts(parts)).toBeNull();
});
it("builds a spec from patch parts", () => {
const parts = [
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "main" },
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/main",
value: { type: "Card", props: { title: "Hello" }, children: [] },
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(spec!.root).toBe("main");
expect(spec!.elements["main"]).toEqual({
type: "Card",
props: { title: "Hello" },
children: [],
});
});
it("handles flat spec parts", () => {
const parts = [
{
type: "data-spec",
data: {
type: "flat",
spec: {
root: "card-1",
elements: {
"card-1": { type: "Card", props: {}, children: [] },
},
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(spec!.root).toBe("card-1");
expect(spec!.elements["card-1"]).toBeDefined();
});
it("ignores non-spec parts", () => {
const parts = [
{ type: "text", text: "Some text" },
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "main" },
},
},
{ type: "tool-invocation", data: { toolName: "search" } },
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(spec!.root).toBe("main");
});
it("applies patches incrementally", () => {
const parts = [
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "main" },
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/main",
value: { type: "Stack", props: {}, children: ["child"] },
},
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/child",
value: { type: "Text", props: { content: "Hi" }, children: [] },
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(Object.keys(spec!.elements)).toHaveLength(2);
expect(spec!.elements["child"]!.props.content).toBe("Hi");
});
it("returns null 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" },
];
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
});
it("returns empty string when no text parts", () => {
const parts = [
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "x" },
},
},
];
expect(getTextFromParts(parts)).toBe("");
});
it("ignores non-text parts", () => {
const parts = [
{ type: "text", text: "Before" },
{ type: "data-spec", data: {} },
{ type: "tool-invocation", data: {} },
{ type: "text", text: "After" },
];
expect(getTextFromParts(parts)).toBe("Before\n\nAfter");
});
it("trims whitespace from text parts", () => {
const parts = [
{ type: "text", text: " Hello " },
{ type: "text", text: " World " },
];
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
});
it("skips empty text parts", () => {
const parts = [
{ type: "text", text: "Hello" },
{ type: "text", text: " " },
{ type: "text", text: "World" },
];
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
});
it("ignores text parts with non-string text field", () => {
const parts = [
{ type: "text", text: "Valid" },
{ type: "text", text: undefined as unknown as string },
{ type: "text", text: 42 as unknown as string },
{ type: "text", text: "Also valid" },
];
expect(getTextFromParts(parts)).toBe("Valid\n\nAlso valid");
});
});
function runInStateProvider<T>(
initialState: Record<string, unknown>,
fn: () => T,
): T {
let result: T | undefined;
const Inner = defineComponent({
setup() {
result = fn();
return () => null;
},
});
const App = defineComponent({
setup() {
return () => h(StateProvider, { initialState }, () => h(Inner));
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
return result!;
}
describe("useBoundProp", () => {
it("returns the prop value as-is", () => {
const [value] = runInStateProvider({ name: "test" }, () =>
useBoundProp<string>("hello", undefined),
);
expect(value).toBe("hello");
});
it("returns undefined when prop value is undefined", () => {
const [value] = runInStateProvider({}, () =>
useBoundProp<string>(undefined, undefined),
);
expect(value).toBeUndefined();
});
it("provides a setter function", () => {
const [, setValue] = runInStateProvider({ name: "test" }, () =>
useBoundProp<string>("hello", "/name"),
);
expect(typeof setValue).toBe("function");
});
});
describe("useStateBinding", () => {
it("returns value from state and a setter", () => {
const [value, setValue] = runInStateProvider({ x: 42 }, () =>
useStateBinding<number>("/x"),
);
expect(value).toBe(42);
expect(typeof setValue).toBe("function");
});
it("returns undefined for missing path", () => {
const [value] = runInStateProvider({}, () =>
useStateBinding<string>("/missing"),
);
expect(value).toBeUndefined();
});
});
+688
View File
@@ -0,0 +1,688 @@
import {
ref,
computed,
watch,
onBeforeUnmount,
type ComputedRef,
type Ref,
} from "vue";
import type {
Spec,
UIElement,
FlatElement,
JsonPatch,
SpecDataPart,
} from "@json-render/core";
import {
setByPath,
getByPath,
addByPath,
removeByPath,
createMixedStreamParser,
applySpecPatch,
nestedToFlat,
SPEC_DATA_PART_TYPE,
} from "@json-render/core";
import { useStateStore } from "./composables/state.js";
/**
* Token usage metadata from AI generation
*/
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
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<string, unknown>;
return;
}
if (path.startsWith("/state/")) {
if (!newSpec.state) newSpec.state = {};
const statePath = path.slice("/state".length);
setByPath(newSpec.state as Record<string, unknown>, statePath, value);
return;
}
if (path.startsWith("/elements/")) {
const pathParts = path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return;
if (pathParts.length === 1) {
newSpec.elements[elementKey] = value as UIElement;
} else {
const element = newSpec.elements[elementKey];
if (element) {
const propPath = "/" + pathParts.slice(1).join("/");
const newElement = { ...element };
setByPath(
newElement as unknown as Record<string, unknown>,
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<string, unknown>, 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<string, unknown>,
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<string, unknown>, statePath);
}
return getByPath(spec as unknown as Record<string, unknown>, 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;
}
// =============================================================================
// useUIStream
// =============================================================================
export interface UseUIStreamOptions {
api: string;
onComplete?: (spec: Spec) => void;
onError?: (error: Error) => void;
}
export interface UseUIStreamReturn {
spec: Ref<Spec | null>;
isStreaming: Ref<boolean>;
error: Ref<Error | null>;
usage: Ref<TokenUsage | null>;
rawLines: Ref<string[]>;
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
clear: () => void;
}
export function useUIStream({
api,
onComplete,
onError,
}: UseUIStreamOptions): UseUIStreamReturn {
const spec = ref<Spec | null>(null);
const isStreaming = ref(false);
const error = ref<Error | null>(null);
const usage = ref<TokenUsage | null>(null);
const rawLines = ref<string[]>([]);
let abortController: AbortController | null = null;
const clear = () => {
spec.value = null;
error.value = null;
};
const send = async (
prompt: string,
context?: Record<string, unknown>,
): Promise<void> => {
abortController?.abort();
abortController = new AbortController();
isStreaming.value = true;
error.value = null;
usage.value = null;
rawLines.value = [];
const previousSpec = context?.previousSpec as Spec | undefined;
let currentSpec: Spec =
previousSpec && previousSpec.root
? { ...previousSpec, elements: { ...previousSpec.elements } }
: { root: "", elements: {} };
spec.value = currentSpec;
try {
const response = await fetch(api, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, context, currentSpec }),
signal: abortController.signal,
});
if (!response.ok) {
let errorMessage = `HTTP error: ${response.status}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// Ignore JSON parsing errors
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
const decoder = new TextDecoder();
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.value = result.usage;
} else {
rawLines.value = [...rawLines.value, trimmed];
currentSpec = applyPatch(currentSpec, result.patch);
spec.value = { ...currentSpec };
}
}
}
if (buffer.trim()) {
const trimmed = buffer.trim();
const result = parseLine(trimmed);
if (result) {
if (result.type === "usage") {
usage.value = result.usage;
} else {
rawLines.value = [...rawLines.value, trimmed];
currentSpec = applyPatch(currentSpec, result.patch);
spec.value = { ...currentSpec };
}
}
}
onComplete?.(currentSpec);
} catch (err) {
if ((err as Error).name === "AbortError") {
return;
}
const resolvedError = err instanceof Error ? err : new Error(String(err));
error.value = resolvedError;
onError?.(resolvedError);
} finally {
isStreaming.value = false;
}
};
onBeforeUnmount(() => {
abortController?.abort();
});
return {
spec,
isStreaming,
error,
usage,
rawLines,
send,
clear,
};
}
// =============================================================================
// flatToTree
// =============================================================================
export function flatToTree(elements: FlatElement[]): Spec {
const elementMap: Record<string, UIElement> = {};
let root = "";
for (const element of elements) {
elementMap[element.key] = {
type: element.type,
props: element.props,
children: [],
visible: element.visible,
};
}
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 };
}
// =============================================================================
// buildSpecFromParts / getTextFromParts
// =============================================================================
export interface DataPart {
type: string;
text?: string;
data?: unknown;
}
function isSpecDataPart(data: unknown): data is SpecDataPart {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
switch (obj.type) {
case "patch":
return typeof obj.patch === "object" && obj.patch !== null;
case "flat":
case "nested":
return typeof obj.spec === "object" && obj.spec !== null;
default:
return false;
}
}
export function buildSpecFromParts(parts: DataPart[]): Spec | null {
const spec: Spec = { root: "", elements: {} };
let hasSpec = false;
for (const part of parts) {
if (part.type === SPEC_DATA_PART_TYPE) {
if (!isSpecDataPart(part.data)) continue;
const payload = part.data;
if (payload.type === "patch") {
hasSpec = true;
applySpecPatch(spec, payload.patch);
} else if (payload.type === "flat") {
hasSpec = true;
Object.assign(spec, payload.spec);
} else if (payload.type === "nested") {
hasSpec = true;
const flat = nestedToFlat(payload.spec);
Object.assign(spec, flat);
}
}
}
return hasSpec ? spec : null;
}
export function getTextFromParts(parts: DataPart[]): string {
return parts
.filter(
(p): p is DataPart & { text: string } =>
p.type === "text" && typeof p.text === "string",
)
.map((p) => p.text.trim())
.filter(Boolean)
.join("\n\n");
}
// =============================================================================
// useJsonRenderMessage
// =============================================================================
export function useJsonRenderMessage(parts: Ref<DataPart[]>) {
const result = ref<{ spec: Spec | null; text: string }>({
spec: null,
text: "",
});
let prevParts: DataPart[] = [];
watch(
parts,
(newParts) => {
const partsChanged =
newParts !== prevParts &&
(newParts.length !== prevParts.length ||
newParts[newParts.length - 1] !== prevParts[prevParts.length - 1]);
if (partsChanged || prevParts.length === 0) {
prevParts = newParts;
result.value = {
spec: buildSpecFromParts(newParts),
text: getTextFromParts(newParts),
};
}
},
{ immediate: true, deep: true },
);
return {
spec: computed(() => result.value.spec),
text: computed(() => result.value.text),
hasSpec: computed(() => {
const s = result.value.spec;
return s !== null && Object.keys(s.elements || {}).length > 0;
}),
result,
};
}
// =============================================================================
// useChatUI
// =============================================================================
export interface ChatMessage {
id: string;
role: "user" | "assistant";
text: string;
spec: Spec | null;
}
export interface UseChatUIOptions {
api: string;
onComplete?: (message: ChatMessage) => void;
onError?: (error: Error) => void;
}
export interface UseChatUIReturn {
messages: Ref<ChatMessage[]>;
isStreaming: Ref<boolean>;
error: Ref<Error | null>;
send: (text: string) => Promise<void>;
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}`;
}
export function useChatUI({
api,
onComplete,
onError,
}: UseChatUIOptions): UseChatUIReturn {
const messages = ref<ChatMessage[]>([]);
const isStreaming = ref(false);
const error = ref<Error | null>(null);
let abortController: AbortController | null = null;
const clear = () => {
messages.value = [];
error.value = null;
};
const send = async (text: string): Promise<void> => {
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.value = [...messages.value, userMessage, assistantMessage];
isStreaming.value = true;
error.value = null;
const historyForApi = [
...messages.value
.filter((m) => m.id !== assistantId)
.map((m) => ({
role: m.role,
content: m.text,
})),
];
let accumulatedText = "";
let currentSpec: Spec = { root: "", elements: {} };
let hasSpec = false;
try {
const response = await fetch(api, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: historyForApi }),
signal: abortController.signal,
});
if (!response.ok) {
let errorMessage = `HTTP error: ${response.status}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// Ignore JSON parsing errors
}
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.value = messages.value.map((m) =>
m.id === assistantId
? {
...m,
spec: {
root: currentSpec.root,
elements: { ...currentSpec.elements },
...(currentSpec.state
? { state: { ...currentSpec.state } }
: {}),
},
}
: m,
);
},
onText(line) {
accumulatedText += (accumulatedText ? "\n" : "") + line;
messages.value = messages.value.map((m) =>
m.id === assistantId ? { ...m, text: accumulatedText } : m,
);
},
});
while (true) {
const { done, value } = await reader.read();
if (done) break;
parser.push(decoder.decode(value, { stream: true }));
}
parser.flush();
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 resolvedError = err instanceof Error ? err : new Error(String(err));
error.value = resolvedError;
messages.value = messages.value.filter(
(m) => m.id !== assistantId || m.text.length > 0,
);
onError?.(resolvedError);
} finally {
isStreaming.value = false;
}
};
onBeforeUnmount(() => {
abortController?.abort();
});
return {
messages,
isStreaming,
error,
send,
clear,
};
}
// =============================================================================
// useBoundProp
// =============================================================================
export function useBoundProp<T>(
propValue: T | undefined,
bindingPath: string | undefined,
): [T | undefined, (value: T) => void] {
const { set } = useStateStore();
const setValue = (value: T) => {
if (bindingPath) set(bindingPath, value);
};
return [propValue, setValue];
}
+106
View File
@@ -0,0 +1,106 @@
// Composables – State
export {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
StateKey,
type StateContextValue,
type StateProviderProps,
} from "./composables/state.js";
// Composables – Visibility
export {
VisibilityProvider,
useVisibility,
useIsVisible,
VisibilityKey,
type VisibilityContextValue,
} from "./composables/visibility.js";
// Composables – Actions
export {
ActionProvider,
useActions,
useAction,
ActionKey,
ConfirmDialog,
type ActionContextValue,
type ActionProviderProps,
type PendingConfirmation,
type ConfirmDialogProps,
} from "./composables/actions.js";
// Composables – Validation
export {
ValidationProvider,
useValidation,
useOptionalValidation,
useFieldValidation,
ValidationKey,
type ValidationContextValue,
type ValidationProviderProps,
type FieldValidationState,
} from "./composables/validation.js";
// Composables – Repeat Scope
export {
RepeatScopeProvider,
useRepeatScope,
RepeatScopeKey,
type RepeatScopeValue,
} from "./composables/repeat-scope.js";
// Schema (Vue's spec format)
export { schema, type VueSchema, type VueSpec } from "./schema.js";
// Core types (re-exported for convenience)
export type { Spec, StateStore } from "@json-render/core";
export { createStateStore } from "@json-render/core";
// Catalog-aware types for Vue
export type {
EventHandle,
BaseComponentProps,
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
ActionFn,
Actions,
} from "./catalog-types.js";
// Renderer
export {
defineRegistry,
type DefineRegistryResult,
createRenderer,
type CreateRendererProps,
type ComponentMap,
Renderer,
JSONUIProvider,
type ComponentRenderProps,
type ComponentRenderer,
type ComponentRegistry,
type RendererProps,
type JSONUIProviderProps,
} from "./renderer.js";
// Hooks / composables (streaming, chat, spec utilities)
export {
useUIStream,
useChatUI,
useJsonRenderMessage,
useBoundProp,
flatToTree,
buildSpecFromParts,
getTextFromParts,
type TokenUsage,
type UseUIStreamOptions,
type UseUIStreamReturn,
type UseChatUIOptions,
type UseChatUIReturn,
type ChatMessage,
type DataPart,
} from "./hooks.js";
+384
View File
@@ -0,0 +1,384 @@
import { describe, it, expect } from "vitest";
import { defineComponent, h, createApp, nextTick, type VNode } from "vue";
import type { Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRenderProps,
} from "./renderer.js";
import { useStateStore } from "./composables/state.js";
import { useActions } from "./composables/actions.js";
import { useBoundProp } from "./hooks.js";
import type { EventHandle } from "./catalog-types.js";
// =============================================================================
// Stub components
// =============================================================================
const Button = defineComponent({
name: "Button",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: false },
},
setup(props) {
return () =>
h(
"button",
{
"data-testid": "btn",
onClick: () => (props.emit as (e: string) => void)("press"),
},
(props.element as any).props.label,
);
},
});
const Text = defineComponent({
name: "Text",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: false },
},
setup(props) {
return () => {
const value = (props.element as any).props.text;
return h(
"span",
{ "data-testid": "text" },
value == null
? ""
: typeof value === "string"
? value
: JSON.stringify(value),
);
};
},
});
const Stack = defineComponent({
name: "Stack",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: false },
},
setup(_, { slots }) {
return () => h("div", { "data-testid": "stack" }, slots.default?.());
},
});
const Input = defineComponent({
name: "Input",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: false },
},
setup(props) {
return () => {
const elProps = (props.element as any).props;
const [value, setValue] = useBoundProp<string>(
elProps.value as string | undefined,
(props.bindings as Record<string, string> | undefined)?.value,
);
return h("input", {
"data-testid": "input",
value: value ?? "",
onInput: (e: Event) => setValue((e.target as HTMLInputElement).value),
});
};
},
});
const StateProbe = defineComponent({
name: "StateProbe",
setup() {
const { state } = useStateStore();
return () =>
h("pre", { "data-testid": "state-probe" }, JSON.stringify(state));
},
});
import type { ComponentRegistry } from "./renderer.js";
const registry: ComponentRegistry = { Button, Text, Stack, Input } as any;
// =============================================================================
// Helpers
// =============================================================================
function mountApp(spec: Spec, initialState?: Record<string, unknown>) {
const container = document.createElement("div");
const App = defineComponent({
setup() {
return () =>
h(
JSONUIProvider,
{ registry, initialState: initialState ?? spec.state ?? {} },
() => [h(Renderer, { spec, registry }), h(StateProbe)],
);
},
});
const app = createApp(App);
app.mount(container);
const getState = () => {
const probe = container.querySelector("[data-testid='state-probe']");
return JSON.parse(probe!.textContent!);
};
return { container, app, getState };
}
// =============================================================================
// Tests
// =============================================================================
describe("integration: basic rendering", () => {
it("renders a simple spec with text", async () => {
const spec: Spec = {
root: "main",
elements: {
main: { type: "Text", props: { text: "Hello World" } },
},
};
const { container, app } = mountApp(spec);
await nextTick();
const text = container.querySelector("[data-testid='text']");
expect(text?.textContent).toBe("Hello World");
app.unmount();
});
it("renders nested elements", async () => {
const spec: Spec = {
root: "stack",
elements: {
stack: { type: "Stack", props: {}, children: ["t1", "t2"] },
t1: { type: "Text", props: { text: "First" } },
t2: { type: "Text", props: { text: "Second" } },
},
};
const { container, app } = mountApp(spec);
await nextTick();
const texts = container.querySelectorAll("[data-testid='text']");
expect(texts.length).toBe(2);
expect(texts[0]?.textContent).toBe("First");
expect(texts[1]?.textContent).toBe("Second");
app.unmount();
});
});
describe("integration: state binding", () => {
it("resolves $state expressions in props", async () => {
const spec: Spec = {
root: "main",
state: { greeting: "Hello from state" },
elements: {
main: { type: "Text", props: { text: { $state: "/greeting" } } },
},
};
const { container, app } = mountApp(spec);
await nextTick();
const text = container.querySelector("[data-testid='text']");
expect(text?.textContent).toBe("Hello from state");
app.unmount();
});
});
describe("integration: actions", () => {
it("executes setState via action context", async () => {
let storeRef: ReturnType<typeof useStateStore> | undefined;
let actionsRef: { execute: (b: any) => Promise<void> } | undefined;
const Probe = defineComponent({
name: "ActionProbe",
setup() {
storeRef = useStateStore();
const { execute } = useActions();
actionsRef = { execute };
return () => null;
},
});
const container = document.createElement("div");
const App = defineComponent({
setup() {
return () =>
h(
JSONUIProvider,
{ registry, initialState: { clicked: false } },
() => h(Probe),
);
},
});
const app = createApp(App);
app.mount(container);
await nextTick();
expect(storeRef!.get("/clicked")).toBe(false);
await actionsRef!.execute({
action: "setState",
params: { statePath: "/clicked", value: true },
});
expect(storeRef!.get("/clicked")).toBe(true);
app.unmount();
});
it("chains pushState and setState with $state resolution", async () => {
let storeRef: ReturnType<typeof useStateStore> | undefined;
let actionsRef: { execute: (b: any) => Promise<void> } | undefined;
const Probe = defineComponent({
name: "ActionProbe",
setup() {
storeRef = useStateStore();
const { execute } = useActions();
actionsRef = { execute };
return () => null;
},
});
const container = document.createElement("div");
const App = defineComponent({
setup() {
return () =>
h(
JSONUIProvider,
{
registry,
initialState: { items: ["initial"], observed: "not yet set" },
},
() => h(Probe),
);
},
});
const app = createApp(App);
app.mount(container);
await nextTick();
await actionsRef!.execute({
action: "pushState",
params: { statePath: "/items", value: "new-item" },
});
await actionsRef!.execute({
action: "setState",
params: {
statePath: "/observed",
value: { $state: "/items" },
},
});
expect(storeRef!.get("/items")).toEqual(["initial", "new-item"]);
expect(storeRef!.get("/observed")).toEqual(["initial", "new-item"]);
app.unmount();
});
});
describe("integration: visibility", () => {
it("hides elements when visible is false", async () => {
const spec: Spec = {
root: "stack",
state: { show: false },
elements: {
stack: { type: "Stack", props: {}, children: ["hidden", "visible"] },
hidden: {
type: "Text",
props: { text: "Hidden" },
visible: { $state: "/show" },
},
visible: { type: "Text", props: { text: "Visible" } },
},
};
const { container, app } = mountApp(spec);
await nextTick();
const texts = container.querySelectorAll("[data-testid='text']");
expect(texts.length).toBe(1);
expect(texts[0]?.textContent).toBe("Visible");
app.unmount();
});
it("shows elements when visible condition is true", async () => {
const spec: Spec = {
root: "stack",
state: { show: true },
elements: {
stack: { type: "Stack", props: {}, children: ["conditional"] },
conditional: {
type: "Text",
props: { text: "Shown" },
visible: { $state: "/show" },
},
},
};
const { container, app } = mountApp(spec);
await nextTick();
const texts = container.querySelectorAll("[data-testid='text']");
expect(texts.length).toBe(1);
expect(texts[0]?.textContent).toBe("Shown");
app.unmount();
});
});
describe("integration: repeat", () => {
it("renders repeated elements from state array", async () => {
const spec: Spec = {
root: "list",
state: { items: ["apple", "banana", "cherry"] },
elements: {
list: {
type: "Stack",
props: {},
repeat: { statePath: "/items" },
children: ["item"],
},
item: {
type: "Text",
props: { text: { $item: "." } },
},
},
};
const { container, app } = mountApp(spec);
await nextTick();
const texts = container.querySelectorAll("[data-testid='text']");
expect(texts.length).toBe(3);
app.unmount();
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import { h, createApp, defineComponent } from "vue";
import { Renderer, type ComponentRenderer } from "./renderer.js";
describe("Renderer", () => {
it("renders nothing for null spec", () => {
let rendered = false;
const App = defineComponent({
setup() {
return () => {
rendered = true;
return h(Renderer, { spec: null, registry: {} });
};
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
expect(rendered).toBe(true);
expect(container.innerHTML).toBe("<!---->"); // Vue comment node for null
app.unmount();
});
it("renders nothing for spec without root", () => {
const App = defineComponent({
setup() {
return () =>
h(Renderer, { spec: { root: "", elements: {} }, registry: {} });
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
expect(container.innerHTML).toBe("<!---->");
app.unmount();
});
it("accepts loading prop", () => {
const App = defineComponent({
setup() {
return () => h(Renderer, { spec: null, registry: {}, loading: true });
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
});
it("accepts fallback prop", () => {
const Fallback = defineComponent({
setup() {
return () => h("div", null, "Unknown component");
},
});
const App = defineComponent({
setup() {
return () =>
h(Renderer, {
spec: null,
registry: {},
fallback: Fallback as unknown as ComponentRenderer,
});
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
});
});
+844
View File
@@ -0,0 +1,844 @@
import {
defineComponent,
h,
provide,
inject,
computed,
unref,
watch,
onErrorCaptured,
type Component,
type ComputedRef,
type DefineComponent,
type PropType,
type Ref,
type VNode,
type InjectionKey,
} from "vue";
import type {
UIElement,
Spec,
ActionBinding,
Catalog,
SchemaDefinition,
StateStore,
ComputedFunction,
} from "@json-render/core";
import {
resolveElementProps,
resolveBindings,
resolveActionParam,
evaluateVisibility,
getByPath,
type PropResolutionContext,
} from "@json-render/core";
import type {
Components,
Actions,
ActionFn,
SetState,
StateModel,
CatalogHasActions,
EventHandle,
} from "./catalog-types.js";
import { useVisibility } from "./composables/visibility.js";
import { useActions, ConfirmDialog } from "./composables/actions.js";
import { useStateStore, StateProvider } from "./composables/state.js";
import { VisibilityProvider } from "./composables/visibility.js";
import { ActionProvider } from "./composables/actions.js";
import { ValidationProvider } from "./composables/validation.js";
import {
RepeatScopeProvider,
useRepeatScope,
} from "./composables/repeat-scope.js";
// ---------------------------------------------------------------------------
// ComponentRenderProps
// ---------------------------------------------------------------------------
export interface ComponentRenderProps<P = Record<string, unknown>> {
element: UIElement<string, P>;
children?: VNode[];
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
loading?: boolean;
}
export type ComponentRenderer<P = Record<string, unknown>> = Component<
ComponentRenderProps<P>
>;
export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
// ---------------------------------------------------------------------------
// FunctionsContext
// ---------------------------------------------------------------------------
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
const FunctionsKey: InjectionKey<
| Ref<Record<string, ComputedFunction>>
| ComputedRef<Record<string, ComputedFunction>>
> = Symbol("json-render-functions");
function useFunctions():
| Ref<Record<string, ComputedFunction>>
| ComputedRef<Record<string, ComputedFunction>> {
return inject(
FunctionsKey,
computed(() => EMPTY_FUNCTIONS),
);
}
// ---------------------------------------------------------------------------
// ElementErrorBoundary
// ---------------------------------------------------------------------------
const ElementErrorBoundary = defineComponent({
name: "ElementErrorBoundary",
props: {
elementType: { type: String, required: true },
},
setup(props, { slots }) {
onErrorCaptured((error, _instance, info) => {
console.error(
`[json-render] Rendering error in <${props.elementType}>:`,
error,
info,
);
return false;
});
return () => slots.default?.();
},
});
// ---------------------------------------------------------------------------
// ElementRenderer & RepeatChildren
// ---------------------------------------------------------------------------
// These two components reference each other recursively, so we declare them
// with explicit type annotations to satisfy DTS generation.
interface ElementRendererExposed {}
interface RepeatChildrenExposed {}
const ElementRenderer: DefineComponent<
{
element: { type: PropType<UIElement>; required: true };
spec: { type: PropType<Spec>; required: true };
registry: { type: PropType<ComponentRegistry>; required: true };
loading: { type: BooleanConstructor; default: false };
fallback: { type: PropType<ComponentRenderer>; default: undefined };
},
ElementRendererExposed
> = defineComponent({
name: "ElementRenderer",
props: {
element: { type: Object as PropType<UIElement>, required: true },
spec: { type: Object as PropType<Spec>, required: true },
registry: {
type: Object as PropType<ComponentRegistry>,
required: true,
},
loading: { type: Boolean, default: false },
fallback: {
type: [Object, Function] as PropType<ComponentRenderer>,
default: undefined,
},
},
setup(props): () => VNode | null {
const repeatScope = useRepeatScope();
const { ctx } = useVisibility();
const { execute } = useActions();
const storeCtx = useStateStore();
const functionsRef = useFunctions();
const fullCtx = computed<PropResolutionContext>(() => {
const base: PropResolutionContext = repeatScope
? {
...ctx,
repeatItem: repeatScope.item,
repeatIndex: repeatScope.index,
repeatBasePath: repeatScope.basePath,
}
: { ...ctx };
base.functions = unref(functionsRef);
return base;
});
// Watch effect for element.watch
let prevWatchValues: Record<string, unknown> | null = null;
watch(
() => {
const watchConfig = props.element.watch;
if (!watchConfig) return undefined;
const values: Record<string, unknown> = {};
for (const path of Object.keys(watchConfig)) {
values[path] = getByPath(storeCtx.state, path);
}
return values;
},
(watchedValues, _old, onCleanup) => {
const watchConfig = props.element.watch;
if (!watchConfig || !watchedValues) return;
const prev = prevWatchValues;
prevWatchValues = watchedValues;
// Skip initial
if (prev === null) return;
let cancelled = false;
onCleanup(() => {
cancelled = true;
});
const paths = Object.keys(watchConfig);
void (async () => {
for (const path of paths) {
if (cancelled) break;
if (watchedValues[path] !== prev[path]) {
const binding = watchConfig[path];
if (!binding) continue;
const bindings = Array.isArray(binding) ? binding : [binding];
for (const b of bindings) {
if (cancelled) break;
if (!b.params) {
await execute(b);
continue;
}
const liveCtx: PropResolutionContext = {
...fullCtx.value,
stateModel: storeCtx.getSnapshot(),
};
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, liveCtx);
}
await execute({ ...b, params: resolved });
}
}
}
})().catch(console.error);
},
{ deep: true },
);
return (): VNode | null => {
const element = props.element;
const currentCtx = fullCtx.value;
// Visibility
const isVisible =
element.visible === undefined
? true
: evaluateVisibility(element.visible, currentCtx);
if (!isVisible) return null;
// Bindings & props
const rawProps = element.props as Record<string, unknown>;
const elementBindings = resolveBindings(rawProps, currentCtx);
const resolvedProps = resolveElementProps(rawProps, currentCtx);
const resolvedElement =
resolvedProps !== element.props
? { ...element, props: resolvedProps }
: element;
// Lookup component
const Comp = props.registry[resolvedElement.type] ?? props.fallback;
if (!Comp) {
console.warn(`No renderer for component type: ${resolvedElement.type}`);
return null;
}
// Build emit / on
const onBindings = element.on;
const emit = async (eventName: string): Promise<void> => {
const binding = onBindings?.[eventName];
if (!binding) return;
const actionBindings = Array.isArray(binding) ? binding : [binding];
for (const b of actionBindings) {
if (!b.params) {
await execute(b);
continue;
}
const liveCtx: PropResolutionContext = {
...currentCtx,
stateModel: storeCtx.getSnapshot(),
};
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, liveCtx);
}
await execute({ ...b, params: resolved });
}
};
const on = (eventName: string): EventHandle => {
const binding = onBindings?.[eventName];
if (!binding) {
return { emit: () => {}, shouldPreventDefault: false, bound: false };
}
const actionBindings = Array.isArray(binding) ? binding : [binding];
const shouldPreventDefault = actionBindings.some(
(b) => b.preventDefault,
);
return {
emit: () => emit(eventName),
shouldPreventDefault,
bound: true,
};
};
// Render children
let children: VNode[] | undefined;
if (resolvedElement.repeat) {
children = [
h(RepeatChildren, {
element: resolvedElement,
spec: props.spec,
registry: props.registry,
loading: props.loading,
fallback: props.fallback,
}),
];
} else if (resolvedElement.children) {
children = resolvedElement.children
.map((childKey): VNode | null => {
const childElement = props.spec.elements[childKey];
if (!childElement) {
if (!props.loading) {
console.warn(
`[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
);
}
return null;
}
return h(ElementRenderer, {
key: childKey,
element: childElement,
spec: props.spec,
registry: props.registry,
loading: props.loading,
fallback: props.fallback,
});
})
.filter((v): v is VNode => v !== null);
}
return h(
ElementErrorBoundary,
{ elementType: resolvedElement.type },
() =>
h(
Comp as Component,
{
element: resolvedElement,
emit,
on,
bindings: elementBindings,
loading: props.loading,
},
children ? () => children : undefined,
),
);
};
},
// Cast required: ElementRenderer and RepeatChildren reference each other
// recursively, which prevents TypeScript from inferring the component type.
// The explicit DefineComponent annotation above provides the correct type.
}) as any;
const RepeatChildren: DefineComponent<
{
element: { type: PropType<UIElement>; required: true };
spec: { type: PropType<Spec>; required: true };
registry: { type: PropType<ComponentRegistry>; required: true };
loading: { type: BooleanConstructor; default: false };
fallback: { type: PropType<ComponentRenderer>; default: undefined };
},
RepeatChildrenExposed
> = defineComponent({
name: "RepeatChildren",
props: {
element: { type: Object as PropType<UIElement>, required: true },
spec: { type: Object as PropType<Spec>, required: true },
registry: {
type: Object as PropType<ComponentRegistry>,
required: true,
},
loading: { type: Boolean, default: false },
fallback: {
type: [Object, Function] as PropType<ComponentRenderer>,
default: undefined,
},
},
setup(props): () => VNode[] {
const storeCtx = useStateStore();
return (): VNode[] => {
const repeat = props.element.repeat!;
const statePath = repeat.statePath;
const items =
(getByPath(storeCtx.state, statePath) as unknown[] | undefined) ?? [];
return items.map((itemValue, index): VNode => {
const key =
repeat.key && typeof itemValue === "object" && itemValue !== null
? String(
(itemValue as Record<string, unknown>)[repeat.key] ?? index,
)
: String(index);
const childNodes: VNode[] = (props.element.children ?? [])
.map((childKey): VNode | null => {
const childElement = props.spec.elements[childKey];
if (!childElement) {
if (!props.loading) {
console.warn(
`[json-render] Missing element "${childKey}" referenced as child of "${props.element.type}" (repeat). This element will not render.`,
);
}
return null;
}
return h(ElementRenderer, {
key: childKey,
element: childElement,
spec: props.spec,
registry: props.registry,
loading: props.loading,
fallback: props.fallback,
});
})
.filter((v): v is VNode => v !== null);
return h(
RepeatScopeProvider,
{
key,
item: itemValue,
index,
basePath: `${statePath}/${index}`,
},
() => childNodes,
);
});
};
},
// Cast required: see ElementRenderer comment above (mutual recursion).
}) as any;
// ---------------------------------------------------------------------------
// Renderer
// ---------------------------------------------------------------------------
export interface RendererProps {
spec: Spec | null;
registry: ComponentRegistry;
loading?: boolean;
fallback?: ComponentRenderer;
}
export const Renderer = defineComponent({
name: "Renderer",
props: {
spec: {
type: Object as PropType<Spec | null>,
default: null,
},
registry: {
type: Object as PropType<ComponentRegistry>,
required: true,
},
loading: { type: Boolean, default: false },
fallback: {
type: [Object, Function] as PropType<ComponentRenderer>,
default: undefined,
},
},
setup(props) {
return () => {
if (!props.spec || !props.spec.root) return null;
const rootElement = props.spec.elements[props.spec.root];
if (!rootElement) return null;
return h(ElementRenderer, {
element: rootElement,
spec: props.spec,
registry: props.registry,
loading: props.loading,
fallback: props.fallback,
});
};
},
});
// ---------------------------------------------------------------------------
// ConfirmationDialogManager
// ---------------------------------------------------------------------------
const ConfirmationDialogManager = defineComponent({
name: "ConfirmationDialogManager",
setup() {
const actions = useActions();
return () => {
const pending = actions.pendingConfirmation;
if (!pending?.action.confirm) return null;
return h(ConfirmDialog, {
confirm: pending.action.confirm,
onConfirm: actions.confirm,
onCancel: actions.cancel,
});
};
},
});
// ---------------------------------------------------------------------------
// JSONUIProvider
// ---------------------------------------------------------------------------
export interface JSONUIProviderProps {
registry: ComponentRegistry;
store?: StateStore;
initialState?: Record<string, unknown>;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
navigate?: (path: string) => void;
validationFunctions?: Record<
string,
(value: unknown, args?: Record<string, unknown>) => boolean
>;
functions?: Record<string, ComputedFunction>;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
}
export const JSONUIProvider = defineComponent({
name: "JSONUIProvider",
props: {
registry: {
type: Object as PropType<ComponentRegistry>,
required: true,
},
store: {
type: Object as PropType<StateStore>,
default: undefined,
},
initialState: {
type: Object as PropType<Record<string, unknown>>,
default: () => ({}),
},
handlers: {
type: Object as PropType<
Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>
>,
default: undefined,
},
navigate: {
type: Function as PropType<(path: string) => void>,
default: undefined,
},
validationFunctions: {
type: Object as PropType<
Record<
string,
(value: unknown, args?: Record<string, unknown>) => boolean
>
>,
default: undefined,
},
functions: {
type: Object as PropType<Record<string, ComputedFunction>>,
default: undefined,
},
onStateChange: {
type: Function as PropType<
(changes: Array<{ path: string; value: unknown }>) => void
>,
default: undefined,
},
},
setup(props, { slots }) {
provide(
FunctionsKey,
computed(() => props.functions ?? EMPTY_FUNCTIONS),
);
return () =>
h(
StateProvider,
{
store: props.store,
initialState: props.initialState,
onStateChange: props.onStateChange,
},
() =>
h(VisibilityProvider, null, () =>
h(
ValidationProvider,
{ customFunctions: props.validationFunctions },
() =>
h(
ActionProvider,
{ handlers: props.handlers, navigate: props.navigate },
() => [slots.default?.(), h(ConfirmationDialogManager)],
),
),
),
);
},
});
// ============================================================================
// defineRegistry
// ============================================================================
export interface DefineRegistryResult {
registry: ComponentRegistry;
handlers: (
getSetState: () => SetState | undefined,
getState: () => StateModel,
) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
executeAction: (
actionName: string,
params: Record<string, unknown> | undefined,
setState: SetState,
state?: StateModel,
) => Promise<void>;
}
type DefineRegistryOptions<C extends Catalog> = {
components?: Components<C>;
} & (CatalogHasActions<C> extends true
? { actions: Actions<C> }
: { actions?: Actions<C> });
type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: VNode[];
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
loading?: boolean;
}) => VNode | VNode[] | null;
type DefineRegistryActionFn = (
params: Record<string, unknown> | undefined,
setState: SetState,
state: StateModel,
) => Promise<void>;
export function defineRegistry<C extends Catalog>(
_catalog: C,
options: DefineRegistryOptions<C>,
): DefineRegistryResult {
const registry: ComponentRegistry = {};
if (options.components) {
for (const [name, componentFn] of Object.entries(options.components)) {
registry[name] = defineComponent({
name: `JR_${name}`,
props: {
element: { type: Object as PropType<UIElement>, required: true },
emit: {
type: Function as PropType<(event: string) => void>,
required: true,
},
on: {
type: Function as PropType<(event: string) => EventHandle>,
required: true,
},
bindings: {
type: Object as PropType<Record<string, string>>,
default: undefined,
},
loading: { type: Boolean, default: false },
},
setup(compProps, { slots }) {
return () => {
const children = slots.default?.();
return (componentFn as DefineRegistryComponentFn)({
props: compProps.element.props,
children,
emit: compProps.emit as (event: string) => void,
on: compProps.on as (event: string) => EventHandle,
bindings: compProps.bindings,
loading: compProps.loading,
});
};
},
});
}
}
const actionMap = options.actions
? (Object.entries(options.actions) as Array<
[string, DefineRegistryActionFn]
>)
: [];
const handlers = (
getSetState: () => SetState | undefined,
getState: () => StateModel,
): Record<string, (params: Record<string, unknown>) => Promise<void>> => {
const result: Record<
string,
(params: Record<string, unknown>) => Promise<void>
> = {};
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 executeActionFn = async (
actionName: string,
params: Record<string, unknown> | undefined,
setState: SetState,
state: StateModel = {},
): Promise<void> => {
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: executeActionFn };
}
// ============================================================================
// createRenderer
// ============================================================================
export interface CreateRendererProps {
spec: Spec | null;
store?: StateStore;
state?: Record<string, unknown>;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
functions?: Record<string, ComputedFunction>;
loading?: boolean;
fallback?: ComponentRenderer;
}
export type ComponentMap<
TComponents extends Record<string, { props: unknown }>,
> = {
[K in keyof TComponents]: Component<
ComponentRenderProps<
TComponents[K]["props"] extends { _output: infer O }
? O
: Record<string, unknown>
>
>;
};
export function createRenderer<
TDef extends SchemaDefinition,
TCatalog extends { components: Record<string, { props: unknown }> },
>(
_catalog: Catalog<TDef, TCatalog>,
components: ComponentMap<TCatalog["components"]>,
): Component<CreateRendererProps> {
const registry: ComponentRegistry =
components as unknown as ComponentRegistry;
return defineComponent({
name: "CatalogRenderer",
props: {
spec: { type: Object as PropType<Spec | null>, default: null },
store: { type: Object as PropType<StateStore>, default: undefined },
state: {
type: Object as PropType<Record<string, unknown>>,
default: undefined,
},
onAction: {
type: Function as PropType<
(actionName: string, params?: Record<string, unknown>) => void
>,
default: undefined,
},
onStateChange: {
type: Function as PropType<
(changes: Array<{ path: string; value: unknown }>) => void
>,
default: undefined,
},
functions: {
type: Object as PropType<Record<string, ComputedFunction>>,
default: undefined,
},
loading: { type: Boolean, default: false },
fallback: {
type: [Object, Function] as PropType<ComponentRenderer>,
default: undefined,
},
},
setup(props) {
provide(
FunctionsKey,
computed(() => props.functions ?? EMPTY_FUNCTIONS),
);
return () => {
const actionHandlers = props.onAction
? new Proxy(
{} as Record<
string,
(params: Record<string, unknown>) => void | Promise<void>
>,
{
get: (_target, prop: string) => {
return (params: Record<string, unknown>) =>
props.onAction!(prop, params);
},
has: () => true,
},
)
: undefined;
return h(
StateProvider,
{
store: props.store,
initialState: props.state,
onStateChange: props.onStateChange,
},
() =>
h(VisibilityProvider, null, () =>
h(ValidationProvider, null, () =>
h(ActionProvider, { handlers: actionHandlers }, () => [
h(Renderer, {
spec: props.spec,
registry,
loading: props.loading,
fallback: props.fallback,
}),
h(ConfirmationDialogManager),
]),
),
),
);
};
},
});
}
+80
View File
@@ -0,0 +1,80 @@
import { defineSchema } from "@json-render/core";
/**
* The schema for @json-render/vue
*
* Defines:
* - Spec: A flat tree of elements with keys, types, props, and children references
* - Catalog: Components with props schemas, and optional actions
*/
export const schema = defineSchema(
(s) => ({
spec: s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
visible: s.any(),
}),
),
}),
catalog: s.object({
components: s.map({
props: s.zod(),
slots: s.array(s.string()),
description: s.string(),
example: s.any(),
}),
actions: s.map({
params: s.zod(),
description: s.string(),
}),
}),
}),
{
builtInActions: [
{
name: "setState",
description:
"Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
},
{
name: "pushState",
description:
'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
},
{
name: "removeState",
description:
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
},
{
name: "validateForm",
description:
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean }.",
},
],
defaultRules: [
"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.",
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","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.',
"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": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> 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 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.",
],
},
);
export type VueSchema = typeof schema;
export type VueSpec<TCatalog> = typeof schema extends {
createCatalog: (catalog: TCatalog) => { _specType: infer S };
}
? S
: never;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/vue-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/schema.ts"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
external: ["vue", "@json-render/core"],
});
+5190 -44
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -3,11 +3,10 @@ import path from "path";
export default defineConfig({
resolve: {
// Deduplicate React so tests don't get two copies
// (pnpm strict resolution can cause packages/react to resolve a different copy)
alias: {
react: path.resolve(__dirname, "node_modules/react"),
"react-dom": path.resolve(__dirname, "node_modules/react-dom"),
vue: path.resolve(__dirname, "node_modules/vue"),
},
},
test: {