mirror of
https://github.com/kennethnym/jrx.git
synced 2026-03-20 11:51:17 +00:00
Add example: Bun HTTP server showcasing jrx JSX-to-JSON rendering
Bun React app with HMR that demonstrates jrx's render() pipeline. Shows JSX source, live UI via @json-render/react, and JSON output side by side. - example/specs/simple.tsx: flat Stack > Text + Button - example/specs/full.tsx: nested layout with state, events, visibility conditions, and watchers - Uses defineCatalog + defineRegistry from @json-render/react - Fix package.json exports to match actual tsup output (.js/.cjs instead of .mjs/.js) Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
192
example/src/App.tsx
Normal file
192
example/src/App.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Renderer, StateProvider, ActionProvider, VisibilityProvider } from "@json-render/react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { registry, handlers } from "./registry";
|
||||
|
||||
const SPECS = ["simple", "full"] as const;
|
||||
|
||||
type SetState = (fn: (prev: Record<string, unknown>) => Record<string, unknown>) => void;
|
||||
|
||||
function SpecRenderer({ spec }: { spec: Spec }) {
|
||||
const [state, setState] = useState<Record<string, unknown>>(spec.state ?? {});
|
||||
const stateRef = useRef(state);
|
||||
const setStateRef = useRef<SetState>(setState);
|
||||
stateRef.current = state;
|
||||
setStateRef.current = setState;
|
||||
|
||||
const actionHandlers = useMemo(
|
||||
() => handlers(() => setStateRef.current, () => stateRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<StateProvider initialState={state}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [activeSpec, setActiveSpec] = useState<string | null>(null);
|
||||
const [spec, setSpec] = useState<Spec | null>(null);
|
||||
const [specJson, setSpecJson] = useState<string>("");
|
||||
const [jsxSource, setJsxSource] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSpec) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
fetch(`/api/spec/${activeSpec}`).then((r) => r.json()),
|
||||
fetch(`/api/source/${activeSpec}`).then((r) => r.text()),
|
||||
])
|
||||
.then(([data, source]) => {
|
||||
setSpec(data);
|
||||
setSpecJson(JSON.stringify(data, null, 2));
|
||||
setJsxSource(source);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [activeSpec]);
|
||||
|
||||
if (!activeSpec) {
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: "60px auto", padding: "0 24px" }}>
|
||||
<h1 style={{ fontSize: "24px", marginBottom: "8px" }}>jrx examples</h1>
|
||||
<p style={{ color: "#666", fontSize: "14px", marginBottom: "24px" }}>
|
||||
JSX → json-render Spec. Pick a spec to see the live UI and JSON
|
||||
output.
|
||||
</p>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{SPECS.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => setActiveSpec(name)}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
fontSize: "15px",
|
||||
backgroundColor: "#fff",
|
||||
border: "1px solid #d0d0d0",
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<strong>{name}</strong>
|
||||
<span style={{ color: "#888", marginLeft: "8px" }}>
|
||||
{name === "simple"
|
||||
? "Flat elements, no state"
|
||||
: "Nested layout with state, events, visibility, watchers"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
borderBottom: "1px solid #e0e0e0",
|
||||
backgroundColor: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveSpec(null);
|
||||
setSpec(null);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#4f46e5",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<h1 style={{ fontSize: "18px" }}>{activeSpec} spec</h1>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ padding: "24px", color: "#888" }}>Loading...</p>
|
||||
) : (
|
||||
spec && (
|
||||
<div style={{ padding: "24px", display: "flex", flexDirection: "column", gap: "24px" }}>
|
||||
{/* JSX Source */}
|
||||
<div>
|
||||
<h2 style={{ margin: "0 0 12px 0", fontSize: "16px", color: "#111" }}>
|
||||
JSX Source
|
||||
</h2>
|
||||
<pre
|
||||
style={{
|
||||
backgroundColor: "#1e1e2e",
|
||||
color: "#cdd6f4",
|
||||
padding: "16px",
|
||||
borderRadius: "8px",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.5",
|
||||
overflow: "auto",
|
||||
maxHeight: "40vh",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{jsxSource}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Live UI + JSON side by side */}
|
||||
<div style={{ display: "flex", gap: "24px", alignItems: "flex-start" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h2 style={{ margin: "0 0 12px 0", fontSize: "16px", color: "#111" }}>
|
||||
Live UI
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
<SpecRenderer spec={spec} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h2 style={{ margin: "0 0 12px 0", fontSize: "16px", color: "#111" }}>
|
||||
JSON Output
|
||||
</h2>
|
||||
<pre
|
||||
style={{
|
||||
backgroundColor: "#1e1e2e",
|
||||
color: "#cdd6f4",
|
||||
padding: "16px",
|
||||
borderRadius: "8px",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.5",
|
||||
overflow: "auto",
|
||||
maxHeight: "80vh",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{specJson}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
example/src/catalog.ts
Normal file
55
example/src/catalog.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Stack: {
|
||||
props: z.object({
|
||||
direction: z.enum(["vertical", "horizontal"]).nullable(),
|
||||
gap: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Flex container for layouts",
|
||||
},
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Container card for grouping content",
|
||||
},
|
||||
Text: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
}),
|
||||
description: "Display text",
|
||||
},
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
}),
|
||||
description: "Clickable button. Bind on.press for handler.",
|
||||
},
|
||||
Input: {
|
||||
props: z.object({
|
||||
placeholder: z.string().nullable(),
|
||||
}),
|
||||
description: "Text input field",
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
increment: {
|
||||
params: z.object({
|
||||
statePath: z.string(),
|
||||
}),
|
||||
description: "Increment a numeric state value by 1",
|
||||
},
|
||||
logCountry: {
|
||||
params: z.object({
|
||||
country: z.string(),
|
||||
}),
|
||||
description: "Log the selected country",
|
||||
},
|
||||
},
|
||||
});
|
||||
17
example/src/frontend.tsx
Normal file
17
example/src/frontend.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
createRoot(elem).render(app);
|
||||
}
|
||||
16
example/src/index.html
Normal file
16
example/src/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>jrx example</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #111; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
94
example/src/registry.tsx
Normal file
94
example/src/registry.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
export const { registry, handlers } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Stack: ({ props, children }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: props.direction === "horizontal" ? "row" : "column",
|
||||
gap: props.gap === "lg" ? "16px" : props.gap === "sm" ? "4px" : "12px",
|
||||
padding: "16px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
Card: ({ props, children }) => (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #d0d0d0",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
backgroundColor: "#fafafa",
|
||||
}}
|
||||
>
|
||||
{props.title && (
|
||||
<h3 style={{ margin: "0 0 12px 0", fontSize: "16px", color: "#333" }}>
|
||||
{props.title}
|
||||
</h3>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
Text: ({ props }) => (
|
||||
<span style={{ fontSize: "14px", color: "#444" }}>
|
||||
{String(props.content ?? "")}
|
||||
</span>
|
||||
),
|
||||
|
||||
Button: ({ props, emit }) => (
|
||||
<button
|
||||
onClick={() => emit("press")}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
fontSize: "14px",
|
||||
backgroundColor: "#4f46e5",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
|
||||
Input: ({ props, emit }) => (
|
||||
<input
|
||||
placeholder={props.placeholder ?? ""}
|
||||
onChange={() => emit("change")}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
border: "1px solid #d0d0d0",
|
||||
borderRadius: "6px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
actions: {
|
||||
increment: async (params, setState) => {
|
||||
if (!params) return;
|
||||
const path = params.statePath;
|
||||
const key = path.replace(/^\//, "");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[key]: ((prev[key] as number) ?? 0) + 1,
|
||||
}));
|
||||
},
|
||||
|
||||
logCountry: async (params) => {
|
||||
if (!params) return;
|
||||
console.log("logCountry:", params.country);
|
||||
},
|
||||
},
|
||||
});
|
||||
49
example/src/server.ts
Normal file
49
example/src/server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { serve } from "bun";
|
||||
import index from "./index.html";
|
||||
import { simpleSpec } from "../specs/simple";
|
||||
import { fullSpec } from "../specs/full";
|
||||
|
||||
const specs: Record<string, object> = {
|
||||
simple: simpleSpec,
|
||||
full: fullSpec,
|
||||
};
|
||||
|
||||
const sourceFiles: Record<string, string> = {
|
||||
simple: "specs/simple.tsx",
|
||||
full: "specs/full.tsx",
|
||||
};
|
||||
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
|
||||
const server = serve({
|
||||
port,
|
||||
routes: {
|
||||
// Serve the React app for all unmatched routes
|
||||
"/*": index,
|
||||
|
||||
"/api/spec/:name": (req) => {
|
||||
const spec = specs[req.params.name];
|
||||
if (!spec) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
return Response.json(spec);
|
||||
},
|
||||
|
||||
"/api/source/:name": async (req) => {
|
||||
const file = sourceFiles[req.params.name];
|
||||
if (!file) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
const source = await Bun.file(file).text();
|
||||
return new Response(source, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
},
|
||||
},
|
||||
development: process.env.NODE_ENV !== "production" && {
|
||||
hmr: true,
|
||||
console: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`jrx example server running at ${server.url}`);
|
||||
Reference in New Issue
Block a user