Skip to content
edmi-ui

JSX Preview

Live preview of streamed JSX or template markup that tolerates unclosed tags.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Rendered
Join MAG4
Magnificent Four · 4 tokens
Streaming
Join MAG4
Error
Rendered
Join MAG4
Magnificent Four · 4 tokens
Streaming
Join MAG4
Error
Rendered
Join MAG4
Magnificent Four · 4 tokens
Streaming
Join MAG4
Error

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.

Terminal window
npx shadcn@latest add @edmi-ui/ai-jsx-preview
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-jsx-preview
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-jsx-preview
Terminal window
bunx shadcn@latest add @edmi-ui/ai-jsx-preview

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-jsx-preview
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-jsx-preview
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-jsx-preview
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-jsx-preview

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-jsx-preview.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-jsx-preview.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-jsx-preview.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-jsx-preview.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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>
registry/components/ai/jsx-preview.tsx
"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";
registry/components/ai/jsx-preview/JSXPreview.vue
<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>
registry/components/ai/jsx-preview/JSXPreviewContent.ts
// 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),
);
},
});
registry/components/ai/jsx-preview/JSXPreviewError.vue
<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>
registry/components/ai/jsx-preview/context.ts
// 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;
}
registry/components/ai/jsx-preview/parser.ts
// 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> = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
"&nbsp;": " ",
};
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;
}
registry/components/ai/jsx-preview/index.ts
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";
src/lib/registry/ai/jsx-preview/jsx-preview-content.svelte
<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>
src/lib/registry/ai/jsx-preview/jsx-preview-error.svelte
<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}
src/lib/registry/ai/jsx-preview/jsx-preview-nodes.svelte
<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}
src/lib/registry/ai/jsx-preview/jsx-preview.svelte
<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>
src/lib/registry/ai/jsx-preview/parser.ts
// 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> = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
"&nbsp;": " ",
};
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;
}
src/lib/registry/ai/jsx-preview/use-jsx-preview.svelte.ts
// 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;
}
src/lib/registry/ai/jsx-preview/index.ts
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
  • The error state is the destructive alert with a “Could not render” title.
  • React uses react-jsx-parser; Vue and Svelte ship a small sandboxed renderer. jsx is JSX-like markup (tags, quoted or {braced} attributes, {binding} text) that is parsed, never evaluated; only components in the components map render, event handlers and javascript: URLs are dropped, unknown capitalised tags report Unknown component <X> on line N., and streamed markup has its unclosed tags closed. Use class (className also works) in the markup.