JSX Preview
Live preview of streamed JSX or template markup that tolerates unclosed tags.
import { JSXPreview, JSXPreviewContent, JSXPreviewError,} from "@/components/ai/jsx-preview";import { Button } from "@/components/ui/button";import { Card } from "@/components/ui/card";import { Skeleton } from "@/components/ui/skeleton";
const components = { Card: (props: React.ComponentProps<typeof Card>) => ( <Card className="gap-0 px-4" size="sm" {...props} /> ), Button, Skeleton,};
const rendered = `<Card> <div className="font-semibold">Join MAG4</div> <div className="mt-0.5 text-xs text-muted-foreground">Magnificent Four · 4 tokens</div> <div className="mt-3.5 flex gap-2"> <Button elevation="raised">Join index</Button> <Button variant="outline">Details</Button> </div></Card>`;
// The model is still writing: <Card> and the last <div> are unclosed.const streaming = `<Card> <div className="font-semibold">Join MAG4</div> <Skeleton className="mt-2 h-3 w-48" /> <Skeleton className="mt-3.5 h-[34px] w-32" />`;
const broken = `<Card> <div className="font-semibold">Join MAG4</div> <Chartt data={`;
export default function Demo() { return ( <div className="flex w-full max-w-3xl flex-col gap-5"> <div className="grid gap-5 sm:grid-cols-2"> <div className="flex flex-col gap-2"> <span className="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase"> Rendered </span> <JSXPreview components={components} jsx={rendered}> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> <div className="flex flex-col gap-2"> <span className="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase"> Streaming </span> <JSXPreview components={components} isStreaming jsx={streaming}> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> </div> <div className="flex max-w-sm flex-col gap-2"> <span className="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase"> Error </span> <JSXPreview components={components} jsx={broken}> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> </div> );}<script setup lang="ts">import { JSXPreview, JSXPreviewContent, JSXPreviewError } from "@/components/ai/jsx-preview";import { Button } from "@/components/ui/button";import { Card } from "@/components/ui/card";import { Skeleton } from "@/components/ui/skeleton";import { defineComponent, h } from "vue";
// Only components in this map can be rendered; markup is parsed, never evaluated.const PreviewCard = defineComponent({ setup: (_, { slots }) => () => h(Card, { size: "sm", class: "gap-0 px-4" }, slots),});const components = { Card: PreviewCard, Button, Skeleton };
const rendered = `<Card> <div class="font-semibold">Join MAG4</div> <div class="mt-0.5 text-xs text-muted-foreground">Magnificent Four · 4 tokens</div> <div class="mt-3.5 flex gap-2"> <Button elevation="raised">Join index</Button> <Button variant="outline">Details</Button> </div></Card>`;
// The model is still writing: <Card> is unclosed.const streaming = `<Card> <div class="font-semibold">Join MAG4</div> <Skeleton class="mt-2 h-3 w-48" /> <Skeleton class="mt-3.5 h-[34px] w-32" />`;
const broken = `<Card> <div class="font-semibold">Join MAG4</div> <Chartt /></Card>`;</script>
<template> <div class="flex w-full max-w-3xl flex-col gap-5"> <div class="grid gap-5 sm:grid-cols-2"> <div class="flex flex-col gap-2"> <span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase">Rendered</span> <JSXPreview :components="components" :jsx="rendered"> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> <div class="flex flex-col gap-2"> <span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase">Streaming</span> <JSXPreview :components="components" is-streaming :jsx="streaming"> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> </div> <div class="flex max-w-sm flex-col gap-2"> <span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase">Error</span> <JSXPreview :components="components" :jsx="broken"> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> </div></template><script lang="ts"> import { JSXPreview, JSXPreviewContent, JSXPreviewError } from "$lib/components/ai/jsx-preview"; import { Button } from "$lib/components/ui/button"; import { Card } from "$lib/components/ui/card"; import { Skeleton } from "$lib/components/ui/skeleton";
// Only components in this map can be rendered; markup is parsed, never evaluated. const components = { Card, Button, Skeleton };
const rendered = `<Card size="sm" class="gap-0 px-4"> <div class="font-semibold">Join MAG4</div> <div class="mt-0.5 text-xs text-muted-foreground">Magnificent Four · 4 tokens</div> <div class="mt-3.5 flex gap-2"> <Button elevation="raised">Join index</Button> <Button variant="outline">Details</Button> </div></Card>`;
// The model is still writing: <Card> is unclosed. const streaming = `<Card size="sm" class="gap-0 px-4"> <div class="font-semibold">Join MAG4</div> <Skeleton class="mt-2 h-3 w-48" /> <Skeleton class="mt-3.5 h-[34px] w-32" />`;
const broken = `<Card> <div class="font-semibold">Join MAG4</div> <Chartt /></Card>`;</script>
<div class="flex w-full max-w-3xl flex-col gap-5"> <div class="grid gap-5 sm:grid-cols-2"> <div class="flex flex-col gap-2"> <span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase" >Rendered</span > <JSXPreview {components} jsx={rendered}> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> <div class="flex flex-col gap-2"> <span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase" >Streaming</span > <JSXPreview {components} isStreaming jsx={streaming}> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> </div> <div class="flex max-w-sm flex-col gap-2"> <span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase">Error</span> <JSXPreview {components} jsx={broken}> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div></div>Parses the markup (it is never evaluated) and renders the error through alert. Unclosed tags are closed while isStreaming is true, and the last good render stays on screen if a streaming chunk does not parse.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-jsx-previewpnpm dlx shadcn@latest add @edmi-ui/ai-jsx-previewyarn dlx shadcn@latest add @edmi-ui/ai-jsx-previewbunx shadcn@latest add @edmi-ui/ai-jsx-previewPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install react-jsx-parserTerminal window pnpm add react-jsx-parserTerminal window yarn add react-jsx-parserTerminal window bun add react-jsx-parserAdd the Edmi components this one builds on:
alert.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-jsx-previewpnpm dlx shadcn-vue@latest add @edmi-ui/ai-jsx-previewyarn dlx shadcn-vue@latest add @edmi-ui/ai-jsx-previewbunx shadcn-vue@latest add @edmi-ui/ai-jsx-previewPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @lucide/vueTerminal window pnpm add @lucide/vueTerminal window yarn add @lucide/vueTerminal window bun add @lucide/vueAdd the Edmi components this one builds on:
alert.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-jsx-preview.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-jsx-preview.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-jsx-preview.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-jsx-preview.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
alert.Copy the code from Component source into your project and update the import paths to match your aliases.
import { JSXPreview, JSXPreviewContent, JSXPreviewError,} from "@/components/ai/jsx-preview";
<div className="flex w-full max-w-3xl flex-col gap-5"> <div className="grid gap-5 sm:grid-cols-2"> <div className="flex flex-col gap-2"> <span className="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase"> Rendered </span> <JSXPreview> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> <div className="flex flex-col gap-2"> <span className="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase"> Streaming </span> <JSXPreview isStreaming> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> </div> {/* … */}</div><script setup lang="ts">import { JSXPreview, JSXPreviewContent, JSXPreviewError,} from "@/components/ai/jsx-preview";</script>
<template> <div class="flex w-full max-w-3xl flex-col gap-5"> <div class="grid gap-5 sm:grid-cols-2"> <div class="flex flex-col gap-2"> <span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase">Rendered</span> <JSXPreview> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> <div class="flex flex-col gap-2"> <span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase">Streaming</span> <JSXPreview is-streaming> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> </div> <div class="flex max-w-sm flex-col gap-2"> <span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase">Error</span> <JSXPreview> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> </div></template><script lang="ts"> import { JSXPreview, JSXPreviewContent, JSXPreviewError, } from "$lib/components/ai/jsx-preview";</script>
<div class="flex w-full max-w-3xl flex-col gap-5"> <div class="grid gap-5 sm:grid-cols-2"> <div class="flex flex-col gap-2"> <span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase" >Rendered</span > <JSXPreview> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> <div class="flex flex-col gap-2"> <span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase" >Streaming</span > <JSXPreview isStreaming> <JSXPreviewContent /> <JSXPreviewError /> </JSXPreview> </div> </div> <!-- … --></div>Component source
Section titled “Component source”"use client";
import { cn } from "cn";import type { ComponentProps, ReactNode } from "react";import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState,} from "react";import type { TProps as JsxParserProps } from "react-jsx-parser";import JsxParser from "react-jsx-parser";import { WarningCircleIcon } from "@phosphor-icons/react";import { Alert, AlertDescription, AlertTitle } from "@/registry/edmi/ui/alert";
interface JSXPreviewContextValue { jsx: string; processedJsx: string; isStreaming: boolean; error: Error | null; setError: (error: Error | null) => void; setLastGoodJsx: (jsx: string) => void; components: JsxParserProps["components"]; bindings: JsxParserProps["bindings"]; onErrorProp?: (error: Error) => void;}
const JSXPreviewContext = createContext<JSXPreviewContextValue | null>(null);
const TAG_REGEX = /<\/?([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*?)(\/)?>/;
export const useJSXPreview = () => { const context = useContext(JSXPreviewContext); if (!context) { throw new Error("JSXPreview components must be used within JSXPreview"); } return context;};
const matchJsxTag = (code: string) => { if (code.trim() === "") { return null; }
const match = code.match(TAG_REGEX);
if (!match || match.index === undefined) { return null; }
const [fullMatch, tagName, attributes, selfClosing] = match;
let type: "self-closing" | "closing" | "opening"; if (selfClosing) { type = "self-closing"; } else if (fullMatch.startsWith("</")) { type = "closing"; } else { type = "opening"; }
return { attributes: attributes.trim(), endIndex: match.index + fullMatch.length, startIndex: match.index, tag: fullMatch, tagName, type, };};
const stripIncompleteTag = (text: string) => { // Find the last '<' that isn't part of a complete tag const lastOpen = text.lastIndexOf("<"); if (lastOpen === -1) { return text; }
const afterOpen = text.slice(lastOpen); // If there's no closing '>' after the last '<', it's an incomplete tag if (!afterOpen.includes(">")) { return text.slice(0, lastOpen); }
return text;};
const completeJsxTag = (code: string) => { const stack: string[] = []; let result = ""; let currentPosition = 0;
while (currentPosition < code.length) { const match = matchJsxTag(code.slice(currentPosition)); if (!match) { // No more tags found, strip any trailing incomplete tag result += stripIncompleteTag(code.slice(currentPosition)); break; } const { tagName, type, endIndex } = match;
// Include any text content before this tag result += code.slice(currentPosition, currentPosition + endIndex);
if (type === "opening") { stack.push(tagName); } else if (type === "closing") { stack.pop(); }
currentPosition += endIndex; }
return ( result + stack .toReversed() .map((tag) => `</${tag}>`) .join("") );};
export type JSXPreviewProps = ComponentProps<"div"> & { jsx: string; isStreaming?: boolean; components?: JsxParserProps["components"]; bindings?: JsxParserProps["bindings"]; onError?: (error: Error) => void;};
export const JSXPreview = memo( ({ jsx, isStreaming = false, components, bindings, onError, className, children, ...props }: JSXPreviewProps) => { const [prevJsx, setPrevJsx] = useState(jsx); const [error, setError] = useState<Error | null>(null); const [_lastGoodJsx, setLastGoodJsx] = useState("");
// Clear error when jsx changes (derived state pattern) if (jsx !== prevJsx) { setPrevJsx(jsx); setError(null); }
const processedJsx = useMemo( () => (isStreaming ? completeJsxTag(jsx) : jsx), [jsx, isStreaming], );
// biome-ignore lint/correctness/useExhaustiveDependencies: setError is stable const contextValue = useMemo( () => ({ bindings, components, error, isStreaming, jsx, onErrorProp: onError, processedJsx, setError, setLastGoodJsx, }), [ bindings, components, error, isStreaming, jsx, onError, processedJsx, setError, ], );
return ( <JSXPreviewContext.Provider value={contextValue}> <div data-slot="ai-jsx-preview" className={cn("relative", className)} {...props} > {children} </div> </JSXPreviewContext.Provider> ); },);
JSXPreview.displayName = "JSXPreview";
export type JSXPreviewContentProps = Omit<ComponentProps<"div">, "children">;
export const JSXPreviewContent = memo( ({ className, ...props }: JSXPreviewContentProps) => { const { processedJsx, isStreaming, components, bindings, setError, setLastGoodJsx, onErrorProp, } = useJSXPreview(); const errorReportedRef = useRef<string | null>(null); const lastGoodJsxRef = useRef(""); const [hadError, setHadError] = useState(false);
// Reset error tracking when jsx changes // biome-ignore lint/correctness/useExhaustiveDependencies: re-run whenever the processed JSX changes useEffect(() => { errorReportedRef.current = null; setHadError(false); }, [processedJsx]);
const handleError = useCallback( (err: Error) => { // Prevent duplicate error reports for the same jsx if (errorReportedRef.current === processedJsx) { return; } errorReportedRef.current = processedJsx;
// JsxParser reports errors while it renders: defer the state updates out of that render pass. queueMicrotask(() => { // During streaming, suppress errors and fall back to last good JSX if (isStreaming) { setHadError(true); return; }
setError(err); onErrorProp?.(err); }); }, [processedJsx, isStreaming, onErrorProp, setError], );
// Track the last JSX that rendered without error useEffect(() => { if (!errorReportedRef.current) { lastGoodJsxRef.current = processedJsx; setLastGoodJsx(processedJsx); } }, [processedJsx, setLastGoodJsx]);
// During streaming, if the current JSX errored, re-render with last good version const displayJsx = isStreaming && hadError ? lastGoodJsxRef.current : processedJsx;
return ( <div className={cn("jsx-preview-content", className)} {...props}> <JsxParser bindings={bindings} components={components} jsx={displayJsx} onError={handleError} renderInWrapper={false} /> </div> ); },);
JSXPreviewContent.displayName = "JSXPreviewContent";
export type JSXPreviewErrorProps = ComponentProps<"div"> & { children?: ReactNode | ((error: Error) => ReactNode);};
const renderChildren = ( children: ReactNode | ((error: Error) => ReactNode), error: Error,): ReactNode => { if (typeof children === "function") { return children(error); } return children;};
export const JSXPreviewError = memo( ({ className, children, ...props }: JSXPreviewErrorProps) => { const { error } = useJSXPreview();
if (!error) { return null; }
if (children) { return ( <Alert className={className} data-slot="ai-jsx-preview-error" variant="destructive" {...props} > {renderChildren(children, error)} </Alert> ); }
return ( <Alert className={className} data-slot="ai-jsx-preview-error" variant="destructive" {...props} > <WarningCircleIcon /> <AlertTitle>Could not render</AlertTitle> <AlertDescription>{error.message}</AlertDescription> </Alert> ); },);
JSXPreviewError.displayName = "JSXPreviewError";<script setup lang="ts">// Edmi ✦ port: AI Elements has no Vue JSX Preview. Markup is parsed by ./parser (no eval), rendered with `components`.import type { Component, HTMLAttributes } from "vue"import type { JsxNode } from "./parser"import { computed, provide, ref, watch } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { JSXPreviewKey } from "./context"import { findUnknownComponent, parseJsx } from "./parser"
const props = withDefaults(defineProps<{ /** Markup to render: JSX-like tags, quoted or `{braced}` attributes, `{binding}` text. */ jsx: string isStreaming?: boolean components?: Record<string, Component | string> bindings?: Record<string, unknown> class?: HTMLAttributes["class"]}>(), { isStreaming: false,})
const emit = defineEmits<{ (e: "error", error: Error): void}>()
const lastGood = ref<JsxNode[]>([])const error = ref<Error | null>(null)
const parsed = computed(() => { try { const nodes = parseJsx(props.jsx, { streaming: props.isStreaming }) const unknown = findUnknownComponent(nodes, props.components) if (unknown) throw unknown return { nodes, error: null as Error | null } } catch (e) { return { nodes: null, error: e instanceof Error ? e : new Error(String(e)) } }})
watch(parsed, (result) => { if (result.nodes) { lastGood.value = result.nodes error.value = null return } // While streaming, a chunk that does not parse yet keeps the last good render and stays silent. if (props.isStreaming) return error.value = result.error if (result.error) emit("error", result.error)}, { immediate: true })
provide(JSXPreviewKey, { nodes: computed(() => parsed.value.nodes ?? lastGood.value), error, isStreaming: computed(() => props.isStreaming), components: computed(() => props.components), bindings: computed(() => props.bindings),})</script>
<template> <div data-slot="ai-jsx-preview" :class="cn('relative', props.class)"> <slot /> </div></template>// Edmi ✦ port: renders the parsed tree with `h()`. Event handlers and `javascript:` URLs are dropped.import type { Component, HTMLAttributes, PropType, VNodeChild } from "vue";import { defineComponent, h } from "vue";import { cn } from "@/registry/edmi/lib/utils";import { useJSXPreview } from "./context";import type { JsxAttr, JsxNode } from "./parser";
const ATTR_ALIASES: Record<string, string> = { className: "class", htmlFor: "for",};
const resolveBinding = ( bindings: Record<string, unknown> | undefined, path: string,) => path .split(".") .reduce<unknown>( (acc, key) => (acc as Record<string, unknown> | undefined)?.[key], bindings, );
const resolveAttr = ( value: JsxAttr, bindings: Record<string, unknown> | undefined,) => typeof value === "object" && value !== null ? resolveBinding(bindings, value.binding) : value;
function renderNodes( nodes: JsxNode[], components: Record<string, Component | string> | undefined, bindings: Record<string, unknown> | undefined,): VNodeChild[] { return nodes.map((node) => { if (node.type === "text") { return node.value; } if (node.type === "binding") { const value = resolveBinding(bindings, node.name); return value == null ? "" : String(value); }
const props: Record<string, unknown> = {}; for (const [key, raw] of Object.entries(node.attrs)) { if (/^on[A-Z@]/.test(key) || key.startsWith("@")) { continue; } const name = ATTR_ALIASES[key] ?? key; const value = resolveAttr(raw, bindings); if ( typeof value === "string" && /^(href|src|action)$/i.test(name) && /^\s*javascript:/i.test(value) ) { continue; } props[name] = value; }
const children = renderNodes(node.children, components, bindings); const target = components?.[node.tag]; if (target && typeof target !== "string") { return h(target, props, { default: () => children }); } return h(typeof target === "string" ? target : node.tag, props, children); });}
export default defineComponent({ name: "JSXPreviewContent", props: { class: { type: [String, Array, Object] as PropType<HTMLAttributes["class"]>, default: undefined, }, }, setup(props) { const { nodes, components, bindings } = useJSXPreview(); return () => h( "div", { class: cn("jsx-preview-content", props.class), "data-slot": "ai-jsx-preview-content", }, renderNodes(nodes.value, components.value, bindings.value), ); },});<script setup lang="ts">// Edmi ✦ port: the error state is the destructive ui alert.import type { HTMLAttributes } from "vue"import { AlertCircle } from "@lucide/vue"import { Alert, AlertDescription, AlertTitle } from "@/registry/edmi/ui/alert"import { useJSXPreview } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { error } = useJSXPreview()</script>
<template> <Alert v-if="error" data-slot="ai-jsx-preview-error" variant="destructive" :class="props.class"> <slot :error="error"> <AlertCircle /> <AlertTitle>Could not render</AlertTitle> <AlertDescription>{{ error.message }}</AlertDescription> </slot> </Alert></template>// Edmi ✦ port: no upstream Vue JSX Preview.import type { Component, ComputedRef, InjectionKey, Ref } from "vue";import { inject } from "vue";import type { JsxNode } from "./parser";
export interface JSXPreviewContext { /** Nodes to render (the last good tree while streaming a chunk that does not parse yet). */ nodes: ComputedRef<JsxNode[]>; error: Ref<Error | null>; isStreaming: ComputedRef<boolean>; components: ComputedRef<Record<string, Component | string> | undefined>; bindings: ComputedRef<Record<string, unknown> | undefined>;}
export const JSXPreviewKey: InjectionKey<JSXPreviewContext> = Symbol("JSXPreview");
export function useJSXPreview() { const ctx = inject(JSXPreviewKey); if (!ctx) { throw new Error("JSXPreview components must be used within JSXPreview"); } return ctx;}// Edmi ✦ port: AI Elements has no Vue JSX Preview, so this is a small, sandboxed markup renderer.// It parses JSX-like markup (tags, quoted attributes, `{literal}` / `{binding}` attributes, `{binding}`// text) into a tree and never evaluates code. Streamed markup is repaired (unclosed tags are closed, a// trailing half-written tag is dropped); components come from an explicit map.
export type JsxAttr = string | number | boolean | null | { binding: string };
export type JsxNode = | { type: "text"; value: string } | { type: "binding"; name: string } | { type: "element"; tag: string; attrs: Record<string, JsxAttr>; children: JsxNode[]; line: number; };
export class JsxPreviewParseError extends Error { line: number; constructor(message: string, line: number) { super(message); this.name = "JsxPreviewParseError"; this.line = line; }}
const VOID_TAGS = new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr",]);
const ENTITIES: Record<string, string> = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'", " ": " ",};
const decode = (text: string) => text.replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (m) => ENTITIES[m] ?? m);
const lineAt = (source: string, index: number) => { let line = 1; for (let i = 0; i < index; i++) { if (source.charCodeAt(i) === 10) { line++; } } return line;};
const NAME = /[A-Za-z][\w.-]*/y;const ATTR_NAME = /[\w:@.-]+/y;
/** Reads `{ ... }` starting at `start` (the `{`), honouring nested braces and quoted strings. Returns the end index (after `}`) or -1. */const readBraces = (source: string, start: number) => { let depth = 0; let quote = ""; for (let i = start; i < source.length; i++) { const ch = source[i]; if (quote) { if (ch === "\\") { i++; } else if (ch === quote) { quote = ""; } } else if (ch === '"' || ch === "'" || ch === "`") { quote = ch; } else if (ch === "{") { depth++; } else if (ch === "}") { depth--; if (depth === 0) { return i + 1; } } } return -1;};
const parseBraceValue = (raw: string): JsxAttr => { const expr = raw.trim(); if (expr === "true") { return true; } if (expr === "false") { return false; } if (expr === "null") { return null; } if (/^-?\d+(\.\d+)?$/.test(expr)) { return Number(expr); } const quoted = expr.match(/^(["'`])([\s\S]*)\1$/); if (quoted) { return quoted[2] ?? ""; } return { binding: expr };};
const pushText = (target: JsxNode[], text: string) => { if (!text || (/^\s*$/.test(text) && text.includes("\n"))) { return; } let last = 0; for (const m of text.matchAll(/\{([\w.$]+)\}/g)) { if (m.index > last) { target.push({ type: "text", value: decode(text.slice(last, m.index)) }); } target.push({ type: "binding", name: m[1] as string }); last = m.index + m[0].length; } if (last < text.length) { target.push({ type: "text", value: decode(text.slice(last)) }); }};
export interface ParseOptions { /** Closes unclosed tags and drops a half-written trailing tag instead of throwing. */ streaming?: boolean;}
/** Parses markup into a node tree. Throws `JsxPreviewParseError` (with a line number) on malformed markup. */export function parseJsx( source: string, options: ParseOptions = {},): JsxNode[] { const root: JsxNode[] = []; const stack: { tag: string; line: number; children: JsxNode[] }[] = []; const current = () => stack[stack.length - 1]?.children ?? root; const fail = (message: string, index: number) => { throw new JsxPreviewParseError(message, lineAt(source, index)); };
let i = 0; while (i < source.length) { if (source[i] !== "<") { const next = source.indexOf("<", i); const end = next === -1 ? source.length : next; pushText(current(), source.slice(i, end)); i = end; continue; }
if (source.startsWith("<!--", i)) { const end = source.indexOf("-->", i); if (end === -1) { if (options.streaming) { break; } fail("Unterminated comment", i); } i = end + 3; continue; }
const tagStart = i; if (source[i + 1] === "/") { const end = source.indexOf(">", i); if (end === -1) { if (options.streaming) { break; } fail("Unterminated closing tag", tagStart); } const name = source.slice(i + 2, end).trim(); const open = stack.pop(); if (!open || open.tag !== name) { if (options.streaming) { if (open) { stack.push(open); } } else { fail( open ? `Expected </${open.tag}> but found </${name}>` : `Unexpected closing tag </${name}>`, tagStart, ); } } i = end + 1; continue; }
NAME.lastIndex = i + 1; const nameMatch = NAME.exec(source); if (!nameMatch) { // A lone "<" in text. if (options.streaming && i + 1 >= source.length) { break; } pushText(current(), "<"); i++; continue; } const tag = nameMatch[0]; i = NAME.lastIndex;
const attrs: Record<string, JsxAttr> = {}; let selfClosing = false; let closed = false; while (i < source.length) { while (i < source.length && /\s/.test(source[i] as string)) { i++; } if (source[i] === ">") { i++; closed = true; break; } if (source[i] === "/" && source[i + 1] === ">") { i += 2; selfClosing = true; closed = true; break; } ATTR_NAME.lastIndex = i; const attrMatch = ATTR_NAME.exec(source); if (!attrMatch) { if (i >= source.length) { break; } fail(`Unexpected character "${source[i]}" in <${tag}>`, i); } const attrName = (attrMatch as RegExpExecArray)[0]; i = ATTR_NAME.lastIndex; if (source[i] !== "=") { attrs[attrName] = true; continue; } i++; const q = source[i]; if (q === '"' || q === "'") { const end = source.indexOf(q, i + 1); if (end === -1) { i = source.length; break; } attrs[attrName] = decode(source.slice(i + 1, end)); i = end + 1; } else if (q === "{") { const end = readBraces(source, i); if (end === -1) { i = source.length; break; } attrs[attrName] = parseBraceValue(source.slice(i + 1, end - 1)); i = end; } else if (i >= source.length) { break; } else { fail(`Attribute ${attrName} needs a quoted or {braced} value`, i); } }
if (!closed) { // The tag itself is still being written. if (options.streaming) { break; } fail(`Unterminated <${tag}>`, tagStart); }
const element: JsxNode = { type: "element", tag, attrs, children: [], line: lineAt(source, tagStart), }; current().push(element); if (!(selfClosing || VOID_TAGS.has(tag))) { stack.push({ tag, line: element.line, children: element.children }); } }
if (stack.length > 0 && !options.streaming) { const open = stack[stack.length - 1] as { tag: string; line: number }; throw new JsxPreviewParseError( `Unclosed <${open.tag}> on line ${open.line}`, open.line, ); } return root;}
/** First capitalised tag that is not in `components`, as an error. Lowercase tags are native elements. */export function findUnknownComponent( nodes: JsxNode[], components: Record<string, unknown> | undefined,): JsxPreviewParseError | null { for (const node of nodes) { if (node.type !== "element") { continue; } if (/^[A-Z]/.test(node.tag) && !components?.[node.tag]) { return new JsxPreviewParseError( `Unknown component <${node.tag}> on line ${node.line}.`, node.line, ); } const nested = findUnknownComponent(node.children, components); if (nested) { return nested; } } return null;}export * from "./context";export { default as JSXPreview } from "./JSXPreview.vue";export { default as JSXPreviewContent } from "./JSXPreviewContent";export { default as JSXPreviewError } from "./JSXPreviewError.vue";export { JsxPreviewParseError, parseJsx } from "./parser";<script lang="ts"> // Edmi ✦ port: renders the parsed tree (see jsx-preview-nodes). import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import JsxPreviewNodes from "./jsx-preview-nodes.svelte"; import { useJSXPreview } from "./use-jsx-preview.svelte.js";
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
const preview = useJSXPreview();</script>
<div data-slot="ai-jsx-preview-content" class={cn("jsx-preview-content", className)} {...restProps}> <JsxPreviewNodes nodes={preview.nodes} components={preview.components} bindings={preview.bindings} /></div><script lang="ts"> // Edmi ✦ port: the error state is the destructive ui alert. import WarningCircleIcon from "phosphor-svelte/lib/WarningCircle"; import { Alert, AlertDescription, AlertTitle } from "$lib/registry/ui/alert/index.js"; import type { Snippet } from "svelte"; import { useJSXPreview } from "./use-jsx-preview.svelte.js";
let { class: className, children }: { class?: string; children?: Snippet<[Error]> } = $props();
const preview = useJSXPreview();</script>
{#if preview.error} <Alert data-slot="ai-jsx-preview-error" variant="destructive" class={className}> {#if children} {@render children(preview.error)} {:else} <WarningCircleIcon /> <AlertTitle>Could not render</AlertTitle> <AlertDescription>{preview.error.message}</AlertDescription> {/if} </Alert>{/if}<script lang="ts"> // Edmi ✦ port: renders the parsed tree recursively. Event handlers and `javascript:` URLs are dropped. import type { Component } from "svelte"; import { type JsxAttr, type JsxNode, VOID_TAGS } from "./parser.js"; import JsxPreviewNodes from "./jsx-preview-nodes.svelte";
let { nodes, components, bindings, }: { nodes: JsxNode[]; // biome-ignore lint/suspicious/noExplicitAny: user-supplied components of any prop shape components?: Record<string, Component<any> | string>; bindings?: Record<string, unknown>; } = $props();
const ATTR_ALIASES: Record<string, string> = { className: "class", htmlFor: "for" };
function resolveBinding(path: string) { return path .split(".") .reduce<unknown>( (acc, key) => (acc as Record<string, unknown> | undefined)?.[key], bindings ); }
function propsOf(attrs: Record<string, JsxAttr>) { const props: Record<string, unknown> = {}; for (const [key, raw] of Object.entries(attrs)) { if (/^on[A-Z]/.test(key)) continue; const name = ATTR_ALIASES[key] ?? key; const value = typeof raw === "object" && raw !== null ? resolveBinding(raw.binding) : raw; if ( typeof value === "string" && /^(href|src|action)$/i.test(name) && /^\s*javascript:/i.test(value) ) { continue; } props[name] = value; } return props; }</script>
{#each nodes as node, i (i)} {#if node.type === "text"} {node.value} {:else if node.type === "binding"} {@const value = resolveBinding(node.name)} {value == null ? "" : String(value)} {:else} {@const target = components?.[node.tag]} {@const props = propsOf(node.attrs)} {#if target && typeof target !== "string"} {@const Target = target} <Target {...props}> {#snippet children()} <JsxPreviewNodes nodes={node.children} {components} {bindings} /> {/snippet} </Target> {:else} {@const tag = typeof target === "string" ? target : node.tag} {#if VOID_TAGS.has(tag)} <svelte:element this={tag} {...props} /> {:else} <svelte:element this={tag} {...props}> <JsxPreviewNodes nodes={node.children} {components} {bindings} /> </svelte:element> {/if} {/if} {/if}{/each}<script lang="ts"> // Edmi ✦ port: AI Elements has no Svelte JSX Preview. Markup is parsed by ./parser (no eval), // rendered with the `components` map. import { cn } from "$lib/utils.js"; import type { Component, Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import { findUnknownComponent, type JsxNode, parseJsx } from "./parser.js"; import { setJSXPreviewContext } from "./use-jsx-preview.svelte.js";
let { jsx, isStreaming = false, components, bindings, onError, class: className, children, ...restProps }: Omit<HTMLAttributes<HTMLDivElement>, "onerror"> & { /** Markup to render: JSX-like tags, quoted or `{braced}` attributes, `{binding}` text. */ jsx: string; isStreaming?: boolean; // biome-ignore lint/suspicious/noExplicitAny: user-supplied components of any prop shape components?: Record<string, Component<any> | string>; bindings?: Record<string, unknown>; onError?: (error: Error) => void; children?: Snippet; } = $props();
const parsed = $derived.by(() => { try { const nodes = parseJsx(jsx, { streaming: isStreaming }); const unknown = findUnknownComponent(nodes, components); if (unknown) throw unknown; return { nodes, error: null as Error | null }; } catch (e) { return { nodes: null, error: e instanceof Error ? e : new Error(String(e)) }; } });
let lastGood = $state.raw<JsxNode[]>([]); let error = $state<Error | null>(null);
$effect(() => { const result = parsed; if (result.nodes) { lastGood = result.nodes; error = null; return; } // While streaming, a chunk that does not parse yet keeps the last good render and stays silent. if (isStreaming) return; error = result.error; if (result.error) onError?.(result.error); });
setJSXPreviewContext({ get nodes() { return parsed.nodes ?? lastGood; }, get error() { return error ?? (!isStreaming ? parsed.error : null); }, get isStreaming() { return isStreaming; }, get components() { return components; }, get bindings() { return bindings; }, });</script>
<div data-slot="ai-jsx-preview" class={cn("relative", className)} {...restProps}> {@render children?.()}</div>// Edmi ✦ port: AI Elements has no Vue JSX Preview, so this is a small, sandboxed markup renderer.// It parses JSX-like markup (tags, quoted attributes, `{literal}` / `{binding}` attributes, `{binding}`// text) into a tree and never evaluates code. Streamed markup is repaired (unclosed tags are closed, a// trailing half-written tag is dropped); components come from an explicit map.
export type JsxAttr = string | number | boolean | null | { binding: string };
export type JsxNode = | { type: "text"; value: string } | { type: "binding"; name: string } | { type: "element"; tag: string; attrs: Record<string, JsxAttr>; children: JsxNode[]; line: number; };
export class JsxPreviewParseError extends Error { line: number; constructor(message: string, line: number) { super(message); this.name = "JsxPreviewParseError"; this.line = line; }}
export const VOID_TAGS = new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr",]);
const ENTITIES: Record<string, string> = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'", " ": " ",};
const decode = (text: string) => text.replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (m) => ENTITIES[m] ?? m);
const lineAt = (source: string, index: number) => { let line = 1; for (let i = 0; i < index; i++) { if (source.charCodeAt(i) === 10) { line++; } } return line;};
const NAME = /[A-Za-z][\w.-]*/y;const ATTR_NAME = /[\w:@.-]+/y;
/** Reads `{ ... }` starting at `start` (the `{`), honouring nested braces and quoted strings. Returns the end index (after `}`) or -1. */const readBraces = (source: string, start: number) => { let depth = 0; let quote = ""; for (let i = start; i < source.length; i++) { const ch = source[i]; if (quote) { if (ch === "\\") { i++; } else if (ch === quote) { quote = ""; } } else if (ch === '"' || ch === "'" || ch === "`") { quote = ch; } else if (ch === "{") { depth++; } else if (ch === "}") { depth--; if (depth === 0) { return i + 1; } } } return -1;};
const parseBraceValue = (raw: string): JsxAttr => { const expr = raw.trim(); if (expr === "true") { return true; } if (expr === "false") { return false; } if (expr === "null") { return null; } if (/^-?\d+(\.\d+)?$/.test(expr)) { return Number(expr); } const quoted = expr.match(/^(["'`])([\s\S]*)\1$/); if (quoted) { return quoted[2] ?? ""; } return { binding: expr };};
const pushText = (target: JsxNode[], text: string) => { if (!text || (/^\s*$/.test(text) && text.includes("\n"))) { return; } let last = 0; for (const m of text.matchAll(/\{([\w.$]+)\}/g)) { if (m.index > last) { target.push({ type: "text", value: decode(text.slice(last, m.index)) }); } target.push({ type: "binding", name: m[1] as string }); last = m.index + m[0].length; } if (last < text.length) { target.push({ type: "text", value: decode(text.slice(last)) }); }};
export interface ParseOptions { /** Closes unclosed tags and drops a half-written trailing tag instead of throwing. */ streaming?: boolean;}
/** Parses markup into a node tree. Throws `JsxPreviewParseError` (with a line number) on malformed markup. */export function parseJsx( source: string, options: ParseOptions = {},): JsxNode[] { const root: JsxNode[] = []; const stack: { tag: string; line: number; children: JsxNode[] }[] = []; const current = () => stack[stack.length - 1]?.children ?? root; const fail = (message: string, index: number) => { throw new JsxPreviewParseError(message, lineAt(source, index)); };
let i = 0; while (i < source.length) { if (source[i] !== "<") { const next = source.indexOf("<", i); const end = next === -1 ? source.length : next; pushText(current(), source.slice(i, end)); i = end; continue; }
if (source.startsWith("<!--", i)) { const end = source.indexOf("-->", i); if (end === -1) { if (options.streaming) { break; } fail("Unterminated comment", i); } i = end + 3; continue; }
const tagStart = i; if (source[i + 1] === "/") { const end = source.indexOf(">", i); if (end === -1) { if (options.streaming) { break; } fail("Unterminated closing tag", tagStart); } const name = source.slice(i + 2, end).trim(); const open = stack.pop(); if (!open || open.tag !== name) { if (options.streaming) { if (open) { stack.push(open); } } else { fail( open ? `Expected </${open.tag}> but found </${name}>` : `Unexpected closing tag </${name}>`, tagStart, ); } } i = end + 1; continue; }
NAME.lastIndex = i + 1; const nameMatch = NAME.exec(source); if (!nameMatch) { // A lone "<" in text. if (options.streaming && i + 1 >= source.length) { break; } pushText(current(), "<"); i++; continue; } const tag = nameMatch[0]; i = NAME.lastIndex;
const attrs: Record<string, JsxAttr> = {}; let selfClosing = false; let closed = false; while (i < source.length) { while (i < source.length && /\s/.test(source[i] as string)) { i++; } if (source[i] === ">") { i++; closed = true; break; } if (source[i] === "/" && source[i + 1] === ">") { i += 2; selfClosing = true; closed = true; break; } ATTR_NAME.lastIndex = i; const attrMatch = ATTR_NAME.exec(source); if (!attrMatch) { if (i >= source.length) { break; } fail(`Unexpected character "${source[i]}" in <${tag}>`, i); } const attrName = (attrMatch as RegExpExecArray)[0]; i = ATTR_NAME.lastIndex; if (source[i] !== "=") { attrs[attrName] = true; continue; } i++; const q = source[i]; if (q === '"' || q === "'") { const end = source.indexOf(q, i + 1); if (end === -1) { i = source.length; break; } attrs[attrName] = decode(source.slice(i + 1, end)); i = end + 1; } else if (q === "{") { const end = readBraces(source, i); if (end === -1) { i = source.length; break; } attrs[attrName] = parseBraceValue(source.slice(i + 1, end - 1)); i = end; } else if (i >= source.length) { break; } else { fail(`Attribute ${attrName} needs a quoted or {braced} value`, i); } }
if (!closed) { // The tag itself is still being written. if (options.streaming) { break; } fail(`Unterminated <${tag}>`, tagStart); }
const element: JsxNode = { type: "element", tag, attrs, children: [], line: lineAt(source, tagStart), }; current().push(element); if (!(selfClosing || VOID_TAGS.has(tag))) { stack.push({ tag, line: element.line, children: element.children }); } }
if (stack.length > 0 && !options.streaming) { const open = stack[stack.length - 1] as { tag: string; line: number }; throw new JsxPreviewParseError( `Unclosed <${open.tag}> on line ${open.line}`, open.line, ); } return root;}
/** First capitalised tag that is not in `components`, as an error. Lowercase tags are native elements. */export function findUnknownComponent( nodes: JsxNode[], components: Record<string, unknown> | undefined,): JsxPreviewParseError | null { for (const node of nodes) { if (node.type !== "element") { continue; } if (/^[A-Z]/.test(node.tag) && !components?.[node.tag]) { return new JsxPreviewParseError( `Unknown component <${node.tag}> on line ${node.line}.`, node.line, ); } const nested = findUnknownComponent(node.children, components); if (nested) { return nested; } } return null;}// Edmi ✦ port: no upstream Svelte JSX Preview.import type { Component } from "svelte";import { getContext, setContext } from "svelte";import type { JsxNode } from "./parser.js";
/** Getter object keeps the context reactive. */export interface JSXPreviewContextValue { /** Nodes to render (the last good tree while streaming a chunk that does not parse yet). */ readonly nodes: JsxNode[]; readonly error: Error | null; readonly isStreaming: boolean; // biome-ignore lint/suspicious/noExplicitAny: user-supplied components of any prop shape readonly components: Record<string, Component<any> | string> | undefined; readonly bindings: Record<string, unknown> | undefined;}
const KEY = Symbol("ai-jsx-preview");
export function setJSXPreviewContext(value: JSXPreviewContextValue) { return setContext(KEY, value);}
export function useJSXPreview(): JSXPreviewContextValue { const ctx = getContext<JSXPreviewContextValue | undefined>(KEY); if (!ctx) { throw new Error("JSXPreview components must be used within <JSXPreview>"); } return ctx;}import Root from "./jsx-preview.svelte";import Content from "./jsx-preview-content.svelte";import ErrorView from "./jsx-preview-error.svelte";
export { JsxPreviewParseError, parseJsx } from "./parser.js";export { useJSXPreview } from "./use-jsx-preview.svelte.js";
export { Content as JSXPreviewContent, ErrorView as JSXPreviewError, Root as JSXPreview,};| Part | Prop | Type | Default |
|---|---|---|---|
| JSXPreview | jsx, isStreaming |
string, boolean |
false |
| JSXPreview | components, bindings, onError |
component map, binding values, callback | |
| JSXPreviewError | children |
ReactNode | (error) => ReactNode |
error message |
✦ Edmi additions
Section titled “✦ Edmi additions”- The error state is the destructive
alertwith a “Could not render” title. - React uses
react-jsx-parser; Vue and Svelte ship a small sandboxed renderer.jsxis JSX-like markup (tags, quoted or{braced}attributes,{binding}text) that is parsed, never evaluated; only components in thecomponentsmap render, event handlers andjavascript:URLs are dropped, unknown capitalised tags reportUnknown component <X> on line N., and streamed markup has its unclosed tags closed. Useclass(classNamealso works) in the markup.