mirror of
https://github.com/kennethnym/jrx.git
synced 2026-03-20 03:41:18 +00:00
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:
3
src/index.ts
Normal file
3
src/index.ts
Normal 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
385
src/integration.test.tsx
Normal 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
7
src/jsx-dev-runtime.ts
Normal 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
141
src/jsx-runtime.ts
Normal 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
283
src/jsx.test.tsx
Normal 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
306
src/render.test.tsx
Normal 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
143
src/render.ts
Normal 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
166
src/spec-validator.test.tsx
Normal 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
42
src/test-components.ts
Normal 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
18
src/test-preload.ts
Normal 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
149
src/types.ts
Normal 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user