Initial implementation of jrx - JSX factory for json-render

JSX factory that compiles JSX trees into json-render Spec JSON.
Framework-agnostic custom jsx-runtime, no React dependency at runtime.

- jsx/jsxs/Fragment via jsxImportSource: "jrx"
- render() flattens JrxNode tree into { root, elements, state? } Spec
- Auto key generation (type-N) with explicit key override
- Full feature parity: visible, on, repeat, watch as reserved props
- Function components via component() or plain functions
- @json-render/core as peer dependency

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
Kenneth Nym
2026-02-27 00:31:52 +00:00
commit f36256dda9
17 changed files with 2019 additions and 0 deletions

3
src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { render } from "./render";
export { isJrxNode, JRX_NODE, FRAGMENT } from "./types";
export type { JrxNode, JrxComponent, RenderOptions } from "./types";

385
src/integration.test.tsx Normal file
View File

@@ -0,0 +1,385 @@
/** @jsxImportSource react */
/**
* Integration tests: verify that Specs produced by jrx are consumable
* by @json-render/react's Renderer.
*
* This file uses React JSX (via the pragma above) for the React component
* tree, and jrx's jsx()/jsxs() via the component wrappers for building Specs.
*/
import { describe, it, expect, mock } from "bun:test";
import React from "react";
import { render as reactRender, act, fireEvent, screen, cleanup } from "@testing-library/react";
import type { Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRenderProps,
} from "@json-render/react";
import { useStateStore } from "@json-render/react";
import { jsx, jsxs } from "./jsx-runtime";
import { render as jrxRender } from "./render";
import {
Stack as JStack,
Card as JCard,
Text as JText,
Button as JButton,
} from "./test-components";
// ---------------------------------------------------------------------------
// React stub components (rendered by @json-render/react's Renderer)
// ---------------------------------------------------------------------------
function Button({ element, emit }: ComponentRenderProps<{ label: string }>) {
return (
<button data-testid="btn" onClick={() => emit("press")}>
{element.props.label}
</button>
);
}
function Text({ element }: ComponentRenderProps<{ content: string }>) {
return <span data-testid="text">{element.props.content}</span>;
}
function Stack({ children }: ComponentRenderProps) {
return <div data-testid="stack">{children}</div>;
}
function Card({ element, children }: ComponentRenderProps<{ title: string }>) {
return (
<div data-testid="card">
<h3>{element.props.title}</h3>
{children}
</div>
);
}
function StateProbe() {
const { state } = useStateStore();
return <pre data-testid="state-probe">{JSON.stringify(state)}</pre>;
}
const registry = { Button, Text, Stack, Card };
// ---------------------------------------------------------------------------
// Helper: render a jrx spec with @json-render/react
// ---------------------------------------------------------------------------
function renderSpec(spec: Spec, handlers?: Record<string, (...args: unknown[]) => void>) {
return reactRender(
<JSONUIProvider registry={registry} initialState={spec.state} handlers={handlers}>
<Renderer spec={spec} registry={registry} />
<StateProbe />
</JSONUIProvider>,
);
}
// =============================================================================
// Basic rendering
// =============================================================================
describe("jrx → @json-render/react round-trip", () => {
it("renders a single element", () => {
const spec = jrxRender(jsx(JText, { content: "Hello from jrx" }));
renderSpec(spec);
expect(screen.getByTestId("text").textContent).toBe("Hello from jrx");
});
it("renders nested elements with children", () => {
const spec = jrxRender(
jsxs(JCard, {
title: "My Card",
children: [jsx(JText, { content: "Inside card" })],
}),
);
renderSpec(spec);
expect(screen.getByTestId("card")).toBeDefined();
expect(screen.getByText("My Card")).toBeDefined();
expect(screen.getByTestId("text").textContent).toBe("Inside card");
});
it("renders a tree with multiple children", () => {
const spec = jrxRender(
jsxs(JStack, {
children: [
jsx(JText, { content: "First" }),
jsx(JText, { content: "Second" }),
jsx(JButton, { label: "Click" }),
],
}),
);
renderSpec(spec);
expect(screen.getByTestId("stack")).toBeDefined();
expect(screen.getByTestId("btn").textContent).toBe("Click");
});
it("renders a deep tree", () => {
const spec = jrxRender(
jsxs(JStack, {
children: [
jsxs(JCard, {
title: "Outer",
children: [jsx(JText, { content: "Deep" })],
}),
],
}),
);
renderSpec(spec);
expect(screen.getByText("Outer")).toBeDefined();
expect(screen.getByTestId("text").textContent).toBe("Deep");
});
});
// =============================================================================
// State + actions (adapted from chained-actions.test.tsx)
// =============================================================================
describe("jrx specs with state and actions", () => {
it("renders with initial state", () => {
const spec = jrxRender(jsx(JText, { content: "Stateful" }), {
state: { count: 42 },
});
renderSpec(spec);
const probe = screen.getByTestId("state-probe");
const state = JSON.parse(probe.textContent!);
expect(state.count).toBe(42);
});
it("setState action updates state on button press", async () => {
const spec = jrxRender(
jsx(JButton, {
label: "Set",
on: {
press: {
action: "setState",
params: { statePath: "/clicked", value: true },
},
},
}),
{ state: { clicked: false } },
);
renderSpec(spec);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = JSON.parse(screen.getByTestId("state-probe").textContent!);
expect(state.clicked).toBe(true);
});
it("chained pushState + setState resolves correctly", async () => {
const spec = jrxRender(
jsx(JButton, {
label: "Chain",
on: {
press: [
{
action: "pushState",
params: { statePath: "/items", value: "new-item" },
},
{
action: "setState",
params: {
statePath: "/observed",
value: { $state: "/items" },
},
},
],
},
}),
{ state: { items: ["initial"], observed: "not yet set" } },
);
renderSpec(spec);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = JSON.parse(screen.getByTestId("state-probe").textContent!);
expect(state.items).toEqual(["initial", "new-item"]);
expect(state.observed).toEqual(["initial", "new-item"]);
});
it("multiple pushState chain resolves correctly", async () => {
const spec = jrxRender(
jsx(JButton, {
label: "Go",
on: {
press: [
{ action: "pushState", params: { statePath: "/items", value: "a" } },
{ action: "pushState", params: { statePath: "/items", value: "b" } },
{
action: "setState",
params: {
statePath: "/snapshot",
value: { $state: "/items" },
},
},
],
},
}),
{ state: { items: [], snapshot: null } },
);
renderSpec(spec);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = JSON.parse(screen.getByTestId("state-probe").textContent!);
expect(state.items).toEqual(["a", "b"]);
expect(state.snapshot).toEqual(["a", "b"]);
});
});
// =============================================================================
// Spec structural validity
// =============================================================================
describe("jrx spec structural validity", () => {
it("all child references resolve to existing elements", () => {
const spec = jrxRender(
jsxs(JStack, {
children: [
jsxs(JCard, {
title: "A",
children: [
jsx(JText, { content: "1" }),
jsx(JText, { content: "2" }),
],
}),
jsx(JButton, { label: "Go" }),
],
}),
);
for (const el of Object.values(spec.elements)) {
if (el.children) {
for (const childKey of el.children) {
expect(
spec.elements[childKey],
`Missing element "${childKey}"`,
).toBeDefined();
}
}
}
});
it("root element exists in elements map", () => {
const spec = jrxRender(jsx(JCard, { title: "Root" }));
expect(spec.elements[spec.root]).toBeDefined();
});
it("element count matches node count", () => {
const spec = jrxRender(
jsxs(JStack, {
children: [
jsx(JCard, { title: "A" }),
jsx(JCard, { title: "B" }),
jsx(JText, { content: "C" }),
],
}),
);
expect(Object.keys(spec.elements)).toHaveLength(4);
});
});
// =============================================================================
// Dynamic features (ported from json-render's dynamic-forms.test.tsx)
// =============================================================================
describe("jrx specs with dynamic features", () => {
it("$state prop expressions resolve at render time", () => {
const spec = jrxRender(
jsx(JText, { content: { $state: "/message" } }),
{ state: { message: "Dynamic hello" } },
);
renderSpec(spec);
expect(screen.getByTestId("text").textContent).toBe("Dynamic hello");
});
it("visibility condition hides element when false", () => {
const spec = jrxRender(
jsxs(JStack, {
children: [
jsx(JText, {
content: "Visible",
visible: { $state: "/show", eq: true },
}),
],
}),
{ state: { show: false } },
);
renderSpec(spec);
expect(screen.queryByTestId("text")).toBeNull();
});
it("visibility condition shows element when true", () => {
cleanup();
const spec = jrxRender(
jsxs(JStack, {
children: [
jsx(JText, {
content: "Visible",
visible: { $state: "/show", eq: true },
}),
],
}),
{ state: { show: true } },
);
renderSpec(spec);
expect(screen.getByTestId("text").textContent).toBe("Visible");
});
it("watchers fire when watched state changes", async () => {
const loadCities = mock();
const spec = jrxRender(
jsxs(JStack, {
children: [
jsx(JButton, {
label: "Set Country",
on: {
press: {
action: "setState",
params: { statePath: "/country", value: "US" },
},
},
}),
jsx(JText, {
content: "watcher",
watch: {
"/country": {
action: "loadCities",
params: { country: { $state: "/country" } },
},
},
}),
],
}),
{ state: { country: "" } },
);
renderSpec(spec, { loadCities });
expect(loadCities).not.toHaveBeenCalled();
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
expect(loadCities).toHaveBeenCalledTimes(1);
expect(loadCities).toHaveBeenCalledWith(
expect.objectContaining({ country: "US" }),
);
});
});

7
src/jsx-dev-runtime.ts Normal file
View File

@@ -0,0 +1,7 @@
// Dev runtime re-exports the production runtime.
// The automatic JSX transform looks for jsx-dev-runtime in development mode.
export { jsx, jsxs, Fragment } from "./jsx-runtime";
export type { JSX } from "./jsx-runtime";
// jsxDEV is the dev-mode factory — same signature as jsx
export { jsx as jsxDEV } from "./jsx-runtime";

141
src/jsx-runtime.ts Normal file
View File

@@ -0,0 +1,141 @@
import type {
ActionBinding,
VisibilityCondition,
} from "@json-render/core";
import { JRX_NODE, FRAGMENT, type JrxNode } from "./types";
import type { JrxComponent } from "./types";
export { FRAGMENT as Fragment };
/** Props reserved by jrx — extracted from JSX props and placed on the UIElement level. */
const RESERVED_PROPS = new Set([
"key",
"children",
"visible",
"on",
"repeat",
"watch",
]);
/**
* Normalize a raw `children` value from JSX props into a flat array of JrxNodes.
* Handles: undefined, single node, nested arrays, and filters out nulls/booleans.
*/
function normalizeChildren(raw: unknown): JrxNode[] {
if (raw == null || typeof raw === "boolean") return [];
if (Array.isArray(raw)) {
const result: JrxNode[] = [];
for (const child of raw) {
if (child == null || typeof child === "boolean") continue;
if (Array.isArray(child)) {
result.push(...normalizeChildren(child));
} else {
result.push(child as JrxNode);
}
}
return result;
}
return [raw as JrxNode];
}
/**
* Extract component props, filtering out reserved prop names.
*/
function extractProps(
rawProps: Record<string, unknown>,
): Record<string, unknown> {
const props: Record<string, unknown> = {};
for (const k of Object.keys(rawProps)) {
if (!RESERVED_PROPS.has(k)) {
props[k] = rawProps[k];
}
}
return props;
}
/** Accepted tag types: string literal, Fragment symbol, or a function component. */
type JsxType = string | typeof FRAGMENT | JrxComponent;
/**
* Core factory — shared by `jsx` and `jsxs`.
*
* If `type` is a function, it is called with props (like React calls
* function components). The function returns a JrxNode directly.
*
* If `type` is a string or Fragment, a JrxNode is constructed inline.
*/
function createNode(
type: JsxType,
rawProps: Record<string, unknown> | null,
): JrxNode {
const p = rawProps ?? {};
// Function component — call it, just like React does.
if (typeof type === "function") {
return type(p);
}
return {
$$typeof: JRX_NODE,
type,
props: extractProps(p),
children: normalizeChildren(p.children),
key: p.key != null ? String(p.key) : undefined,
visible: p.visible as VisibilityCondition | undefined,
on: p.on as
| Record<string, ActionBinding | ActionBinding[]>
| undefined,
repeat: p.repeat as { statePath: string; key?: string } | undefined,
watch: p.watch as
| Record<string, ActionBinding | ActionBinding[]>
| undefined,
};
}
/**
* JSX factory for elements with a single child (or no children).
* Called by the automatic JSX transform (`react-jsx`).
*/
export function jsx(
type: JsxType,
props: Record<string, unknown> | null,
key?: string,
): JrxNode {
const node = createNode(type, props);
if (key != null) node.key = String(key);
return node;
}
/**
* JSX factory for elements with multiple static children.
* Called by the automatic JSX transform (`react-jsx`).
*/
export function jsxs(
type: JsxType,
props: Record<string, unknown> | null,
key?: string,
): JrxNode {
const node = createNode(type, props);
if (key != null) node.key = String(key);
return node;
}
// ---------------------------------------------------------------------------
// JSX namespace — tells TypeScript what JSX expressions are valid
// ---------------------------------------------------------------------------
export namespace JSX {
/** Any string tag is valid — component types come from the catalog at runtime. */
export interface IntrinsicElements {
[tag: string]: Record<string, unknown>;
}
/** The type returned by JSX expressions. */
export type Element = JrxNode;
export interface ElementChildrenAttribute {
children: {};
}
}

283
src/jsx.test.tsx Normal file
View File

@@ -0,0 +1,283 @@
import { describe, it, expect } from "bun:test";
import { render } from "./render";
import { isJrxNode, FRAGMENT } from "./types";
import { jsx, jsxs, Fragment } from "./jsx-runtime";
import {
Stack,
Card,
Text,
Button,
Badge,
List,
ListItem,
Select,
} from "./test-components";
// =============================================================================
// JSX factory basics (direct function calls — tests the factory itself)
// =============================================================================
describe("jsx factory", () => {
it("jsx() with string type returns a JrxNode", () => {
const node = jsx("Card", { title: "Hello" });
expect(isJrxNode(node)).toBe(true);
expect(node.type).toBe("Card");
expect(node.props).toEqual({ title: "Hello" });
});
it("jsx() with component function resolves typeName", () => {
const node = jsx(Card, { title: "Hello" });
expect(isJrxNode(node)).toBe(true);
expect(node.type).toBe("Card");
expect(node.props).toEqual({ title: "Hello" });
});
it("jsxs() returns a JrxNode with children", () => {
const node = jsxs(Stack, {
children: [jsx(Text, { content: "A" }), jsx(Text, { content: "B" })],
});
expect(isJrxNode(node)).toBe(true);
expect(node.children).toHaveLength(2);
});
it("Fragment is the FRAGMENT symbol", () => {
expect(Fragment).toBe(FRAGMENT);
});
it("jsx() extracts key from third argument", () => {
const node = jsx(Card, { title: "Hi" }, "my-key");
expect(node.key).toBe("my-key");
expect(node.props).toEqual({ title: "Hi" });
});
it("jsx() extracts reserved props", () => {
const vis = { $state: "/show" };
const on = { press: { action: "submit" } };
const repeat = { statePath: "/items" };
const watch = { "/x": { action: "reload" } };
const node = jsx(Button, {
label: "Go",
visible: vis,
on,
repeat,
watch,
});
expect(node.props).toEqual({ label: "Go" });
expect(node.visible).toEqual(vis);
expect(node.on).toEqual(on);
expect(node.repeat).toEqual(repeat);
expect(node.watch).toEqual(watch);
});
it("jsx() handles null props", () => {
const node = jsx("Divider", null);
expect(isJrxNode(node)).toBe(true);
expect(node.props).toEqual({});
expect(node.children).toEqual([]);
});
it("jsx() filters null/boolean children", () => {
const node = jsxs(Stack, {
children: [null, jsx(Text, { content: "A" }), false, undefined, true],
});
expect(node.children).toHaveLength(1);
expect(node.children[0].type).toBe("Text");
});
});
// =============================================================================
// JSX syntax → render() integration
// =============================================================================
describe("JSX syntax → render() integration", () => {
it("renders a single element", () => {
const spec = render(<Card title="Hello" />);
expect(spec.root).toBe("card-1");
expect(spec.elements["card-1"].type).toBe("Card");
expect(spec.elements["card-1"].props).toEqual({ title: "Hello" });
});
it("renders nested elements", () => {
const spec = render(
<Card title="Root">
<Text content="Child" />
</Card>,
);
expect(Object.keys(spec.elements)).toHaveLength(2);
expect(spec.elements["card-1"].children).toEqual(["text-1"]);
expect(spec.elements["text-1"].props).toEqual({ content: "Child" });
});
it("renders multiple children", () => {
const spec = render(
<Stack>
<Card title="A" />
<Card title="B" />
<Button label="Click" />
</Stack>,
);
expect(spec.elements["stack-1"].children).toEqual([
"card-1",
"card-2",
"button-1",
]);
});
it("renders deeply nested tree", () => {
const spec = render(
<Stack>
<Card title="Outer">
<Stack>
<Text content="Deep" />
</Stack>
</Card>
</Stack>,
);
expect(Object.keys(spec.elements)).toHaveLength(4);
expect(spec.elements["stack-1"].children).toEqual(["card-1"]);
expect(spec.elements["card-1"].children).toEqual(["stack-2"]);
expect(spec.elements["stack-2"].children).toEqual(["text-1"]);
});
it("handles explicit key prop", () => {
const spec = render(<Card key="main" title="Hello" />);
expect(spec.root).toBe("main");
expect(spec.elements["main"].props).toEqual({ title: "Hello" });
});
it("handles fragments as children", () => {
const spec = render(
<Stack>
<>
<Text content="A" />
<Text content="B" />
</>
<Button label="C" />
</Stack>,
);
expect(spec.elements["stack-1"].children).toEqual([
"text-1",
"text-2",
"button-1",
]);
});
it("handles visible prop", () => {
const spec = render(
<Text content="Conditional" visible={{ $state: "/show" }} />,
);
const el = spec.elements[spec.root];
expect(el.visible).toEqual({ $state: "/show" });
expect((el.props as Record<string, unknown>).visible).toBeUndefined();
});
it("handles on prop", () => {
const spec = render(
<Button label="Submit" on={{ press: { action: "submitForm" } }} />,
);
const el = spec.elements[spec.root];
expect(el.on).toEqual({ press: { action: "submitForm" } });
});
it("handles repeat prop", () => {
const spec = render(
<List repeat={{ statePath: "/items", key: "id" }}>
<ListItem />
</List>,
);
const el = spec.elements[spec.root];
expect(el.repeat).toEqual({ statePath: "/items", key: "id" });
});
it("handles watch prop", () => {
const spec = render(
<Select watch={{ "/country": { action: "loadCities" } }} />,
);
const el = spec.elements[spec.root];
expect(el.watch).toEqual({ "/country": { action: "loadCities" } });
});
it("passes state through render options", () => {
const spec = render(<Card title="Hello" />, { state: { count: 0 } });
expect(spec.state).toEqual({ count: 0 });
});
it("throws on root fragment", () => {
expect(() =>
render(
<>
<Card />
<Text />
</>,
),
).toThrow(/single root element/);
});
it("handles chained actions", () => {
const spec = render(
<Button
label="Multi"
on={{
press: [
{ action: "setState", params: { statePath: "/a", value: 1 } },
{ action: "submitForm" },
],
}}
/>,
);
const el = spec.elements[spec.root];
expect(Array.isArray(el.on!.press)).toBe(true);
expect((el.on!.press as unknown[]).length).toBe(2);
});
it("handles complex visibility conditions", () => {
const spec = render(
<Text
content="Complex"
visible={{
$or: [
{ $state: "/isAdmin" },
{ $state: "/count", gt: 5 },
],
}}
/>,
);
const el = spec.elements[spec.root];
expect(el.visible).toEqual({
$or: [
{ $state: "/isAdmin" },
{ $state: "/count", gt: 5 },
],
});
});
it("produces a valid Spec structure (all children exist)", () => {
const spec = render(
<Stack>
<Card title="A">
<Text content="1" />
<Badge text="tag" />
</Card>
<Card title="B">
<Button label="Click" />
</Card>
</Stack>,
);
for (const [, el] of Object.entries(spec.elements)) {
if (el.children) {
for (const childKey of el.children) {
expect(
spec.elements[childKey],
`Missing element "${childKey}"`,
).toBeDefined();
}
}
}
expect(spec.elements[spec.root]).toBeDefined();
expect(Object.keys(spec.elements)).toHaveLength(6);
});
});

306
src/render.test.tsx Normal file
View File

@@ -0,0 +1,306 @@
import { describe, it, expect } from "bun:test";
import { render } from "./render";
import { FRAGMENT, type JrxNode } from "./types";
import { jsx } from "./jsx-runtime";
import {
Stack,
Card,
Text,
Button,
Badge,
List,
ListItem,
Select,
} from "./test-components";
// =============================================================================
// render() — basic output shape
// =============================================================================
describe("render() output shape", () => {
it("produces a Spec with root and elements", () => {
const spec = render(<Card title="Hello" />);
expect(spec.root).toBeDefined();
expect(spec.elements).toBeDefined();
expect(typeof spec.root).toBe("string");
expect(typeof spec.elements).toBe("object");
});
it("root key points to an existing element", () => {
const spec = render(<Card />);
expect(spec.elements[spec.root]).toBeDefined();
});
it("single element has correct type and props", () => {
const spec = render(<Button label="Click" />);
const el = spec.elements[spec.root];
expect(el.type).toBe("Button");
expect(el.props).toEqual({ label: "Click" });
});
it("single element without children omits children field", () => {
const spec = render(<Text content="hi" />);
const el = spec.elements[spec.root];
expect(el.children).toBeUndefined();
});
});
// =============================================================================
// Key auto-generation
// =============================================================================
describe("key auto-generation", () => {
it("generates keys from lowercase type name", () => {
const spec = render(<Card />);
expect(spec.root).toBe("card-1");
});
it("increments counter for same type", () => {
const spec = render(
<Stack>
<Card title="A" />
<Card title="B" />
</Stack>,
);
const childKeys = spec.elements[spec.root].children!;
expect(childKeys).toEqual(["card-1", "card-2"]);
});
it("uses separate counters per type", () => {
const spec = render(
<Stack>
<Card />
<Text />
<Card />
</Stack>,
);
const childKeys = spec.elements[spec.root].children!;
expect(childKeys).toEqual(["card-1", "text-1", "card-2"]);
});
it("counter resets between render() calls", () => {
const spec1 = render(<Card />);
const spec2 = render(<Card />);
expect(spec1.root).toBe("card-1");
expect(spec2.root).toBe("card-1");
});
});
// =============================================================================
// Explicit key override
// =============================================================================
describe("explicit key override", () => {
it("uses explicit key when provided", () => {
const spec = render(<Card key="main-card" />);
expect(spec.root).toBe("main-card");
});
it("explicit key does not appear in props", () => {
const spec = render(<Card title="Hi" key="my-card" />);
const el = spec.elements["my-card"];
expect(el.props).toEqual({ title: "Hi" });
expect((el.props as Record<string, unknown>).key).toBeUndefined();
});
it("throws on duplicate explicit keys", () => {
expect(() =>
render(
<Stack>
<Card key="same" />
<Text key="same" />
</Stack>,
),
).toThrow(/Duplicate element key "same"/);
});
it("throws when explicit key collides with auto-generated key", () => {
expect(() =>
render(
<Stack>
<Card />
<Button key="card-1" />
</Stack>,
),
).toThrow(/Duplicate element key "card-1"/);
});
});
// =============================================================================
// Nested children
// =============================================================================
describe("nested children", () => {
it("flattens a two-level tree", () => {
const spec = render(
<Card title="Root">
<Text content="Child" />
</Card>,
);
expect(Object.keys(spec.elements)).toHaveLength(2);
expect(spec.elements[spec.root].children).toEqual(["text-1"]);
expect(spec.elements["text-1"].type).toBe("Text");
expect(spec.elements["text-1"].props).toEqual({ content: "Child" });
});
it("flattens a deep tree", () => {
const spec = render(
<Stack>
<Card title="A">
<Text content="Nested" />
</Card>
<Button label="Click" />
</Stack>,
);
expect(Object.keys(spec.elements)).toHaveLength(4);
expect(spec.elements["stack-1"].children).toEqual(["card-1", "button-1"]);
expect(spec.elements["card-1"].children).toEqual(["text-1"]);
expect(spec.elements["text-1"].children).toBeUndefined();
expect(spec.elements["button-1"].children).toBeUndefined();
});
it("all child keys reference existing elements", () => {
const spec = render(
<Stack>
<Card>
<Text />
<Badge />
</Card>
<Button />
</Stack>,
);
for (const el of Object.values(spec.elements)) {
if (el.children) {
for (const childKey of el.children) {
expect(spec.elements[childKey]).toBeDefined();
}
}
}
});
});
// =============================================================================
// Fragment support
// =============================================================================
describe("fragments", () => {
it("expands fragment children inline", () => {
const spec = render(
<Stack>
<>
<Text content="A" />
<Text content="B" />
</>
</Stack>,
);
expect(spec.elements["stack-1"].children).toEqual(["text-1", "text-2"]);
expect(Object.keys(spec.elements)).toHaveLength(3);
});
it("expands nested fragments", () => {
const spec = render(
<Stack>
<>
<>
<Text content="Deep" />
</>
</>
</Stack>,
);
expect(spec.elements["stack-1"].children).toEqual(["text-1"]);
});
it("throws when fragment is at root", () => {
expect(() =>
render(
<>
<Card />
<Text />
</>,
),
).toThrow(/single root element/);
});
});
// =============================================================================
// Reserved prop extraction
// =============================================================================
describe("reserved prop extraction", () => {
it("places visible on UIElement, not in props", () => {
const condition = { $state: "/show" };
const spec = render(<Text content="hi" visible={condition} />);
const el = spec.elements[spec.root];
expect(el.visible).toEqual(condition);
expect((el.props as Record<string, unknown>).visible).toBeUndefined();
});
it("places on bindings on UIElement, not in props", () => {
const onBindings = { press: { action: "submit" } };
const spec = render(<Button label="Go" on={onBindings} />);
const el = spec.elements[spec.root];
expect(el.on).toEqual(onBindings);
expect((el.props as Record<string, unknown>).on).toBeUndefined();
});
it("places repeat on UIElement, not in props", () => {
const repeatConfig = { statePath: "/items", key: "id" };
const spec = render(
<List repeat={repeatConfig}>
<ListItem />
</List>,
);
const el = spec.elements[spec.root];
expect(el.repeat).toEqual(repeatConfig);
expect((el.props as Record<string, unknown>).repeat).toBeUndefined();
});
it("places watch on UIElement, not in props", () => {
const watchConfig = { "/country": { action: "loadCities" } };
const spec = render(<Select watch={watchConfig} />);
const el = spec.elements[spec.root];
expect(el.watch).toEqual(watchConfig);
expect((el.props as Record<string, unknown>).watch).toBeUndefined();
});
it("omits undefined meta fields from UIElement", () => {
const spec = render(<Text content="plain" />);
const el = spec.elements[spec.root];
expect("visible" in el).toBe(false);
expect("on" in el).toBe(false);
expect("repeat" in el).toBe(false);
expect("watch" in el).toBe(false);
});
});
// =============================================================================
// State passthrough
// =============================================================================
describe("state passthrough", () => {
it("includes state in Spec when provided", () => {
const state = { count: 0, items: ["a", "b"] };
const spec = render(<Card />, { state });
expect(spec.state).toEqual(state);
});
it("omits state from Spec when not provided", () => {
const spec = render(<Card />);
expect(spec.state).toBeUndefined();
});
});
// =============================================================================
// Error handling
// =============================================================================
describe("error handling", () => {
it("throws for non-JrxNode input", () => {
expect(() => render({} as JrxNode)).toThrow(/expects a JrxNode/);
});
});

143
src/render.ts Normal file
View File

@@ -0,0 +1,143 @@
import type { Spec, UIElement } from "@json-render/core";
import { FRAGMENT, type JrxNode, type RenderOptions, isJrxNode } from "./types";
/**
* Flatten a JrxNode tree into a json-render `Spec`.
*
* Analogous to `ReactDOM.render` but produces JSON instead of DOM mutations.
*
* @param node - Root JrxNode (produced by JSX)
* @param options - Optional render configuration (e.g. initial state)
* @returns A json-render `Spec` ready for any renderer
*
* @example
* ```tsx
* const spec = render(
* <Card title="Hello">
* <Text content="World" />
* </Card>,
* { state: { count: 0 } }
* );
* ```
*/
export function render(node: JrxNode, options?: RenderOptions): Spec {
if (!isJrxNode(node)) {
throw new Error("render() expects a JrxNode produced by JSX.");
}
if (node.type === FRAGMENT) {
throw new Error(
"render() requires a single root element. Fragments cannot be used at the root level.",
);
}
const counters = new Map<string, number>();
const elements: Record<string, UIElement> = {};
const usedKeys = new Set<string>();
const rootKey = flattenNode(node, elements, counters, usedKeys);
const spec: Spec = { root: rootKey, elements };
if (options?.state) {
spec.state = options.state;
}
return spec;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Generate a unique key for a node based on its type.
* Pattern: `{lowercase-type}-{counter}` (e.g. `card-1`, `card-2`).
*/
function generateKey(
type: string,
counters: Map<string, number>,
): string {
const base = type.toLowerCase();
const count = (counters.get(base) ?? 0) + 1;
counters.set(base, count);
return `${base}-${count}`;
}
/**
* Resolve the children of a node, expanding fragments inline.
* Returns an array of concrete (non-fragment) JrxNodes.
*/
function expandChildren(children: JrxNode[]): JrxNode[] {
const result: JrxNode[] = [];
for (const child of children) {
if (!isJrxNode(child)) continue;
if (child.type === FRAGMENT) {
// Recursively expand nested fragments
result.push(...expandChildren(child.children));
} else {
result.push(child);
}
}
return result;
}
/**
* Recursively flatten a JrxNode into the elements map.
* Returns the key assigned to this node.
*/
function flattenNode(
node: JrxNode,
elements: Record<string, UIElement>,
counters: Map<string, number>,
usedKeys: Set<string>,
): string {
// Determine key
const key = node.key ?? generateKey(node.type as string, counters);
if (usedKeys.has(key)) {
throw new Error(
`Duplicate element key "${key}". Keys must be unique within a single render() call.`,
);
}
usedKeys.add(key);
// Expand fragment children and recursively flatten
const concreteChildren = expandChildren(node.children);
const childKeys: string[] = [];
for (const child of concreteChildren) {
const childKey = flattenNode(child, elements, counters, usedKeys);
childKeys.push(childKey);
}
// Build the UIElement
const element: UIElement = {
type: node.type as string,
props: node.props,
};
if (childKeys.length > 0) {
element.children = childKeys;
}
if (node.visible !== undefined) {
element.visible = node.visible;
}
if (node.on !== undefined) {
element.on = node.on;
}
if (node.repeat !== undefined) {
element.repeat = node.repeat;
}
if (node.watch !== undefined) {
element.watch = node.watch;
}
elements[key] = element;
return key;
}

166
src/spec-validator.test.tsx Normal file
View File

@@ -0,0 +1,166 @@
/**
* Ported from json-render's core/src/spec-validator.test.ts.
*
* Runs @json-render/core's validateSpec against Specs produced by jrx
* to prove structural correctness.
*/
import { describe, it, expect } from "bun:test";
import { validateSpec } from "@json-render/core";
import { render } from "./render";
import {
Stack,
Card,
Text,
Button,
Badge,
List,
ListItem,
Select,
} from "./test-components";
describe("validateSpec on jrx-produced specs", () => {
it("validates a simple single-element spec", () => {
const spec = render(<Text text="hello" />);
const result = validateSpec(spec);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("validates a parent-child spec", () => {
const spec = render(
<Stack>
<Text text="hello" />
</Stack>,
);
const result = validateSpec(spec);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("validates a deep tree", () => {
const spec = render(
<Stack>
<Card title="A">
<Text text="1" />
<Text text="2" />
</Card>
<Button label="Go" />
</Stack>,
);
const result = validateSpec(spec);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("validates spec with visible at element level (not in props)", () => {
const spec = render(
<Text text="conditional" visible={{ $state: "/show" }} />,
);
const result = validateSpec(spec);
expect(result.issues.some((i) => i.code === "visible_in_props")).toBe(false);
});
it("validates spec with on at element level (not in props)", () => {
const spec = render(
<Button label="Click" on={{ press: { action: "submit" } }} />,
);
const result = validateSpec(spec);
expect(result.issues.some((i) => i.code === "on_in_props")).toBe(false);
});
it("validates spec with repeat at element level (not in props)", () => {
const spec = render(
<Stack repeat={{ statePath: "/items" }}>
<Text text="item" />
</Stack>,
);
const result = validateSpec(spec);
expect(result.issues.some((i) => i.code === "repeat_in_props")).toBe(false);
});
it("validates spec with watch at element level (not in props)", () => {
const spec = render(
<Select
label="Country"
watch={{ "/form/country": { action: "loadCities" } }}
/>,
);
const result = validateSpec(spec);
expect(result.issues.some((i) => i.code === "watch_in_props")).toBe(false);
});
it("no orphaned elements in jrx output", () => {
const spec = render(
<Stack>
<Text text="A" />
<Text text="B" />
</Stack>,
);
const result = validateSpec(spec, { checkOrphans: true });
expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(false);
});
it("no missing children in jrx output", () => {
const spec = render(
<Stack>
<Card title="X">
<Text text="nested" />
<Badge text="tag" />
</Card>
<Button label="action" />
</Stack>,
);
const result = validateSpec(spec);
expect(result.issues.some((i) => i.code === "missing_child")).toBe(false);
expect(result.valid).toBe(true);
});
it("validates spec with state", () => {
const spec = render(<Text text="stateful" />, {
state: { count: 0, items: ["a", "b"] },
});
const result = validateSpec(spec);
expect(result.valid).toBe(true);
});
it("validates spec with all features combined", () => {
const spec = render(
<Stack>
<Text text="header" visible={{ $state: "/showHeader" }} />
<List repeat={{ statePath: "/items", key: "id" }}>
<ListItem
title={{ $item: "name" }}
on={{ press: { action: "selectItem" } }}
/>
</List>
<Select
label="Country"
watch={{ "/country": { action: "loadCities" } }}
/>
<Button
label="Submit"
on={{
press: [
{ action: "validateForm" },
{ action: "submitForm" },
],
}}
/>
</Stack>,
{ state: { showHeader: true, items: [], country: "" } },
);
const result = validateSpec(spec);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
for (const el of Object.values(spec.elements)) {
const props = el.props as Record<string, unknown>;
expect(props.visible).toBeUndefined();
expect(props.on).toBeUndefined();
expect(props.repeat).toBeUndefined();
expect(props.watch).toBeUndefined();
}
});
});

42
src/test-components.ts Normal file
View File

@@ -0,0 +1,42 @@
import { jsx } from "./jsx-runtime";
import type { JrxNode } from "./types";
export function Stack(props: Record<string, unknown>): JrxNode {
return jsx("Stack", props);
}
export function Card(props: Record<string, unknown>): JrxNode {
return jsx("Card", props);
}
export function Text(props: Record<string, unknown>): JrxNode {
return jsx("Text", props);
}
export function Button(props: Record<string, unknown>): JrxNode {
return jsx("Button", props);
}
export function Badge(props: Record<string, unknown>): JrxNode {
return jsx("Badge", props);
}
export function List(props: Record<string, unknown>): JrxNode {
return jsx("List", props);
}
export function ListItem(props: Record<string, unknown>): JrxNode {
return jsx("ListItem", props);
}
export function Select(props: Record<string, unknown>): JrxNode {
return jsx("Select", props);
}
export function Input(props: Record<string, unknown>): JrxNode {
return jsx("Input", props);
}
export function Divider(props: Record<string, unknown>): JrxNode {
return jsx("Divider", props);
}

18
src/test-preload.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Window } from "happy-dom";
const window = new Window({ url: "http://localhost" });
// Register DOM globals for @testing-library/react
for (const key of Object.getOwnPropertyNames(window)) {
if (!(key in globalThis)) {
Object.defineProperty(globalThis, key, {
value: (window as unknown as Record<string, unknown>)[key],
writable: true,
configurable: true,
});
}
}
Object.defineProperty(globalThis, "window", { value: window, writable: true, configurable: true });
Object.defineProperty(globalThis, "document", { value: window.document, writable: true, configurable: true });
Object.defineProperty(globalThis, "navigator", { value: window.navigator, writable: true, configurable: true });

149
src/types.ts Normal file
View File

@@ -0,0 +1,149 @@
import type {
ActionBinding,
VisibilityCondition,
} from "@json-render/core";
// ---------------------------------------------------------------------------
// JrxNode — intermediate representation produced by the JSX factory
// ---------------------------------------------------------------------------
/**
* Sentinel symbol identifying a JrxNode (prevents plain objects from
* being mistaken for nodes).
*/
export const JRX_NODE = Symbol.for("jrx.node");
/**
* Sentinel symbol for Fragment grouping.
*/
export const FRAGMENT = Symbol.for("jrx.fragment");
/**
* A node in the intermediate JSX tree.
*
* Created by the `jsx` / `jsxs` factory functions and consumed by `render()`
* which flattens the tree into a json-render `Spec`.
*/
export interface JrxNode {
/** Brand symbol — always `JRX_NODE` */
$$typeof: typeof JRX_NODE;
/**
* Component type name (e.g. `"Card"`, `"Button"`).
* For fragments this is the `FRAGMENT` symbol.
*/
type: string | typeof FRAGMENT;
/** Component props (reserved props already extracted) */
props: Record<string, unknown>;
/** Child nodes */
children: JrxNode[];
// -- Reserved / meta fields (extracted from JSX props) --
/** Explicit element key (overrides auto-generation) */
key: string | undefined;
/** Visibility condition */
visible: VisibilityCondition | undefined;
/** Event bindings */
on: Record<string, ActionBinding | ActionBinding[]> | undefined;
/** Repeat configuration */
repeat: { statePath: string; key?: string } | undefined;
/** State watchers */
watch: Record<string, ActionBinding | ActionBinding[]> | undefined;
}
// ---------------------------------------------------------------------------
// JrxComponent — a function usable as a JSX tag that maps to a type string
// ---------------------------------------------------------------------------
/**
* A jrx component function. Works like a React function component:
* when used as a JSX tag (`<Card />`), the factory calls the function
* with props and gets back a JrxNode.
*/
export type JrxComponent = (props: Record<string, unknown>) => JrxNode;
/**
* Define a jrx component for use as a JSX tag.
*
* Creates a function that, when called with props, produces a JrxNode
* with the given type name — just like a React component returns
* React elements.
*
* @example
* ```tsx
* const Card = component("Card");
* const spec = render(<Card title="Hello"><Text content="World" /></Card>);
* ```
*/
export function component(typeName: string): JrxComponent {
// Import createNodeFromString lazily to avoid circular dep
// (jsx-runtime imports types). Instead, we build the node inline.
return (props: Record<string, unknown>) => {
return {
$$typeof: JRX_NODE,
type: typeName,
props: filterReserved(props),
children: normalizeChildrenRaw(props.children),
key: props.key != null ? String(props.key) : undefined,
visible: props.visible as VisibilityCondition | undefined,
on: props.on as Record<string, ActionBinding | ActionBinding[]> | undefined,
repeat: props.repeat as { statePath: string; key?: string } | undefined,
watch: props.watch as Record<string, ActionBinding | ActionBinding[]> | undefined,
};
};
}
const RESERVED = new Set(["key", "children", "visible", "on", "repeat", "watch"]);
function filterReserved(props: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const k of Object.keys(props)) {
if (!RESERVED.has(k)) out[k] = props[k];
}
return out;
}
function normalizeChildrenRaw(raw: unknown): JrxNode[] {
if (raw == null || typeof raw === "boolean") return [];
if (Array.isArray(raw)) {
const result: JrxNode[] = [];
for (const child of raw) {
if (child == null || typeof child === "boolean") continue;
if (Array.isArray(child)) {
result.push(...normalizeChildrenRaw(child));
} else {
result.push(child as JrxNode);
}
}
return result;
}
return [raw as JrxNode];
}
// ---------------------------------------------------------------------------
// render() options
// ---------------------------------------------------------------------------
export interface RenderOptions {
/** Initial state to include in the Spec output */
state?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Type guard
// ---------------------------------------------------------------------------
export function isJrxNode(value: unknown): value is JrxNode {
return (
typeof value === "object" &&
value !== null &&
(value as JrxNode).$$typeof === JRX_NODE
);
}