import { TYPE_KEY } from "./constants"; /** Options for {@link generativeUIToJSX}. */ export interface GenerativeUIToJSXOptions { /** Escape string children so the output is safe to paste back into a JSX file. Defaults to `false`. */ escape?: boolean; /** Pretty-print nested element trees with two-space indentation. Defaults to `false`. */ pretty?: boolean; } /** * Serializes a generative-UI node to a JSX-like string for display: the "view source" of a model-produced tree. The wire form `{ $type: "Weather", id: "x" }` becomes ``, and nested `children` render between tags: `hello`. A model-provided `$key` becomes the JSX `key` attribute. * * By default this is a faithful textual rendering, not a parser: text children are emitted verbatim (not HTML/JSX-escaped), so the result is meant to be shown, not re-parsed. Returns `""` for nodes that aren't renderable (no `$type` yet, `null`, booleans). * * Pass `{ escape: true }` to make the output safe to paste back into JSX: any string child containing `<`, `>`, `&`, `{`, or `}` is emitted as a JSON-stringified expression (`{JSON.stringify(child)}`) instead of verbatim, while a string child with none of those characters still renders verbatim. * * Pass `{ pretty: true }` to pretty-print nested element trees with two-space indentation, while pure text children stay on one line. */ export function generativeUIToJSX( node: unknown, options?: GenerativeUIToJSXOptions, ): string { return toJSX(node, 0, options?.escape ?? false, options?.pretty ?? false, 0); } /** * The deepest tree we serialize. Input is model-produced, so a runaway/ * adversarial response could nest arbitrarily deep and overflow the stack — past * this depth (far beyond any real UI) we stop. Mirrors the renderer's bound. */ const MAX_DEPTH = 64; /** Characters that would break JSX if emitted verbatim inside a text child. */ const UNSAFE_CHILD_CHARS = /[<>&{}]/; function toJSX( node: unknown, depth: number, escape: boolean, pretty: boolean, indent: number, ): string { if (depth > MAX_DEPTH) return ""; if (node == null || typeof node === "boolean") return ""; if (typeof node === "string") return formatChildText(node, escape); if (typeof node === "number") return String(node); if (Array.isArray(node)) { if (pretty) { return collectBlockLines(node, depth, escape, indent).join("\n"); } return node .map((child) => toJSX(child, depth + 1, escape, false, 0)) .join(""); } if (typeof node !== "object") return ""; const { [TYPE_KEY]: type, $key, children, ...props } = node as Record; if (typeof type !== "string") return ""; const attrs = formatAttr("key", $key) + Object.entries(props) .map(([key, value]) => formatAttr(key, value)) .join(""); if (!pretty) { const inner = children === undefined ? "" : toJSX(children, depth + 1, escape, false, 0); return inner === "" ? `<${type}${attrs} />` : `<${type}${attrs}>${inner}`; } const pad = " ".repeat(indent); if (children === undefined || !hasElementChild(children)) { const inner = children === undefined ? "" : toJSX(children, depth + 1, escape, false, 0); return inner === "" ? `${pad}<${type}${attrs} />` : `${pad}<${type}${attrs}>${inner}`; } const lines = collectBlockLines(children, depth + 1, escape, indent + 1); if (lines.length === 0) { return `${pad}<${type}${attrs} />`; } return `${pad}<${type}${attrs}>\n${lines.join("\n")}\n${pad}`; } function hasElementChild(node: unknown, depth = 0): boolean { if (depth > MAX_DEPTH) return false; if (Array.isArray(node)) return node.some((child) => hasElementChild(child, depth + 1)); return ( node != null && typeof node === "object" && typeof (node as Record)[TYPE_KEY] === "string" ); } function collectBlockLines( node: unknown, depth: number, escape: boolean, indent: number, ): string[] { if (depth > MAX_DEPTH) return []; if (node == null || typeof node === "boolean") return []; if (Array.isArray(node)) { return node.flatMap((child) => collectBlockLines(child, depth + 1, escape, indent), ); } if (typeof node === "string") { return [`${" ".repeat(indent)}${formatChildText(node, escape)}`]; } if (typeof node === "number") { return [`${" ".repeat(indent)}${String(node)}`]; } const rendered = toJSX(node, depth, escape, true, indent); return rendered === "" ? [] : [rendered]; } function formatChildText(value: string, escape: boolean): string { return escape && UNSAFE_CHILD_CHARS.test(value) ? `{${JSON.stringify(value)}}` : value; } /** Formats one prop as a JSX attribute (`id="x"`, `count={3}`, `open`, …). */ function formatAttr(key: string, value: unknown): string { if (value === undefined) return ""; if (value === true) return ` ${key}`; if (typeof value === "string") { // Plain double-quoted form when safe; expression form when a quote or // newline would break the attribute. return /["\n]/.test(value) ? ` ${key}={${JSON.stringify(value)}}` : ` ${key}="${value}"`; } return ` ${key}={${JSON.stringify(value)}}`; }