initial commit
This commit is contained in:
310
components/ai-elements/jsx-preview.tsx
Normal file
310
components/ai-elements/jsx-preview.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
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";
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
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 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
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ? (
|
||||
renderChildren(children, error)
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
<span>{error.message}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
JSXPreviewError.displayName = "JSXPreviewError";
|
||||
Reference in New Issue
Block a user