This commit is contained in:
Light 2026-04-05 01:07:05 +00:00 committed by GitHub
commit 137f3c8d6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1755 additions and 3 deletions

View file

@ -0,0 +1,319 @@
import React, {
memo,
useState,
useEffect,
useRef,
useImperativeHandle,
forwardRef,
useMemo,
} from 'react';
import type { ArtifactFiles } from '~/common';
import { sharedFiles } from '~/utils/artifacts';
import { buildArtifactHtml } from '~/utils/artifacts/artifact-builder';
import { buildImportMap } from '~/utils/artifacts/core';
import {
buildRuntimeFileMap,
extractNpmImports,
normalizeArtifactPath,
resolveArtifactKind,
} from '~/utils/artifacts/helpers';
export interface ArtifactPreviewHandle {
refresh: () => void;
}
interface ArtifactPreviewProps {
files: ArtifactFiles;
fileKey: string;
template?: string; // compat (unused)
previewRef?: React.Ref<ArtifactPreviewHandle>; // compat
sharedProps?: unknown; // compat (unused)
currentCode?: string;
startupConfig?: unknown; // compat (unused)
className?: string;
}
function useDarkMode() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const getDark = () => document.documentElement.classList.contains('dark');
setIsDark(getDark());
const observer = new MutationObserver(() => setIsDark(getDark()));
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
return isDark;
}
function assignRef<T>(ref: React.Ref<T> | undefined, value: T | null) {
if (!ref) return;
if (typeof ref === 'function') ref(value as T);
else (ref as React.MutableRefObject<T | null>).current = value;
}
function collectNpmImportsFromFileMap(fileMap: Record<string, string>) {
const npmImports = new Set<string>();
Object.values(fileMap).forEach((code) => {
extractNpmImports(code).forEach((pkg) => npmImports.add(pkg));
});
return npmImports;
}
export const ArtifactPreview = memo(
forwardRef<ArtifactPreviewHandle, ArtifactPreviewProps>(function ArtifactPreview(
{ files, fileKey, currentCode, className, previewRef,
// compat, intentionally unused:
template, sharedProps, startupConfig,
},
forwardedRef
) {
const isDarkMode = useDarkMode();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [progress, setProgress] = useState('Initializing environment...');
const [error, setError] = useState('');
const initializedRef = useRef(false);
const lastFileKeyRef = useRef<string | null>(null);
const [refreshTick, setRefreshTick] = useState(0);
const lastActivityRef = useRef<number>(Date.now());
const runIdRef = useRef<number>(0);
// keep old heuristic
const normalizedKey = useMemo(
() => (fileKey.startsWith('/') ? fileKey : `/${fileKey}`),
[fileKey]
);
const fileMap = useMemo(() => {
const map: Record<string, string> = {};
Object.entries(files).forEach(([path, fileObj]) => {
const content = typeof fileObj === 'string' ? fileObj : fileObj?.code ?? fileObj?.content;
if (content) map[path.startsWith('/') ? path : `/${path}`] = content;
});
return map;
}, [files]);
const mainCode = useMemo(
() => currentCode ?? fileMap[normalizedKey] ?? '',
[currentCode, fileMap, normalizedKey]
);
const artifactKind = useMemo(
() => resolveArtifactKind(normalizedKey, mainCode),
[normalizedKey, mainCode]
);
const isReact = artifactKind === 'react';
const handle: ArtifactPreviewHandle = {
refresh: () => {
initializedRef.current = false;
setError('');
setStatus('loading');
setProgress('Refreshing preview...');
setRefreshTick((t) => t + 1);
},
};
useImperativeHandle(forwardedRef, () => handle, []);
useEffect(() => {
assignRef(previewRef, handle);
return () => assignRef(previewRef, null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewRef]);
// Single authoritative message handler
useEffect(() => {
const onMsg = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
if (e.origin !== 'null') return;
const d = e.data;
if (!d || typeof d !== 'object') return;
// stale run guard
if (typeof (d as any).runId === 'number' && (d as any).runId !== runIdRef.current) {
return;
}
lastActivityRef.current = Date.now();
if ((d as any).type === 'progress') {
setStatus('loading');
if (typeof (d as any).message === 'string') setProgress((d as any).message);
return;
}
if ((d as any).type === 'artifact-ready') {
setStatus('ready');
return;
}
if ((d as any).type === 'artifact-error') {
setStatus('error');
setError(String((d as any).error || 'Unknown render error'));
return;
}
if ((d as any).type === 'external-link') {
const href = String((d as any).href || '');
try {
const url = new URL(href, window.location.origin);
if (url.protocol === 'http:' || url.protocol === 'https:') {
window.open(url.toString(), '_blank', 'noopener,noreferrer');
}
} catch {
// ignore invalid URL
}
}
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, []);
// Render/re-render effect
useEffect(() => {
const timer = setTimeout(() => {
setStatus('loading');
setError('');
setProgress('Preparing files...');
lastActivityRef.current = Date.now();
const normalizedKey = normalizeArtifactPath(fileKey);
const fileMap = buildRuntimeFileMap({
files,
sharedFiles,
includeShared: isReact,
});
// apply editor override
if (typeof currentCode === 'string') {
fileMap[normalizedKey] = currentCode;
}
const mainCode = fileMap[normalizedKey] ?? '';
const html = buildArtifactHtml({
fileName: normalizedKey,
code: mainCode,
files: fileMap,
isDarkMode,
});
const shouldReset =
!initializedRef.current || !isReact || lastFileKeyRef.current !== normalizedKey;
const iframe = iframeRef.current;
if (!iframe) return;
// new run id for each send cycle
runIdRef.current += 1;
const runId = runIdRef.current;
const sendRenderMessage = () => {
if (!isReact) return;
setProgress('Building dependency map...');
const npmImports = collectNpmImportsFromFileMap(fileMap);
const npmImportMap = buildImportMap(npmImports);
lastActivityRef.current = Date.now();
setProgress('Sending render request...');
iframe.contentWindow?.postMessage(
{
type: 'render',
payload: {
runId,
entryKey: normalizedKey,
files: fileMap,
npmImportMap,
isDarkMode,
},
},
'*'
);
};
if (shouldReset) {
iframe.onload = () => {
// for non-react docs, child renderer should emit artifact-ready itself
if (isReact) sendRenderMessage();
};
iframe.srcdoc = html;
initializedRef.current = isReact;
lastFileKeyRef.current = normalizedKey;
} else {
// react hot update path without full iframe reset
sendRenderMessage();
}
}, 120);
return () => clearTimeout(timer);
}, [fileMap, normalizedKey, mainCode, isDarkMode, isReact, refreshTick]);
// Stall timeout (dynamic): only fail if no activity for 15s
useEffect(() => {
if (status !== 'loading') return;
const id = setInterval(() => {
const idleMs = Date.now() - lastActivityRef.current;
if (idleMs > 15000) {
setStatus('error');
setError('Rendering stalled while loading dependencies (no progress for 15s).');
clearInterval(id);
}
}, 1000);
return () => clearInterval(id);
}, [status]);
return (
<div className={`relative h-full w-full bg-white dark:bg-gray-950 ${className ?? ''}`}>
{status === 'loading' && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/90 dark:bg-gray-950/90 backdrop-blur-sm">
<div className="mb-2 h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
<span className="animate-pulse text-xs font-medium text-gray-500">
{progress || 'Initializing environment...'}
</span>
</div>
)}
{status === 'error' && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-white p-6 dark:bg-gray-950">
<div className="max-h-full w-full max-w-md overflow-auto rounded-xl border border-red-200 bg-red-50 p-4 shadow-sm dark:border-red-900/30 dark:bg-red-900/10">
<h3 className="mb-2 flex items-center gap-2 text-sm font-bold text-red-800 dark:text-red-400">
<span></span> Render Error
</h3>
<pre className="break-all whitespace-pre-wrap font-mono text-[10px] text-red-700 dark:text-red-300">
{error}
</pre>
</div>
</div>
)}
<iframe
ref={iframeRef}
title="Artifact Preview"
className={`h-full w-full border-none transition-opacity duration-300 ${status === 'loading' ? 'opacity-0' : 'opacity-100'
}`}
sandbox="allow-scripts"
/>
</div>
);
})
);
export default ArtifactPreview;

View file

@ -7,7 +7,8 @@ import { useCodeState } from '~/Providers/EditorContext';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider';
import { ArtifactPreview } from './ArtifactPreview';
import { ArtifactPreview, ArtifactPreviewHandle } from './ArtifactRenderer';
import { cn } from '~/utils';
export default function ArtifactTabs({
artifact,
@ -15,8 +16,9 @@ export default function ArtifactTabs({
isSharedConvo,
}: {
artifact: Artifact;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
isSharedConvo?: boolean;
// editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<ArtifactPreviewHandle | null>;
}) {
const { currentCode, setCurrentCode } = useCodeState();
const { data: startupConfig } = useGetStartupConfig();

View file

@ -14,6 +14,7 @@ import ArtifactTabs from './ArtifactTabs';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
import { ArtifactPreviewHandle } from './ArtifactRenderer';
const MAX_BLUR_AMOUNT = 32;
const MAX_BACKDROP_OPACITY = 0.3;
@ -311,7 +312,8 @@ export default function Artifacts() {
<div className="absolute inset-0 flex flex-col">
<ArtifactTabs
artifact={currentArtifact}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
// editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<ArtifactPreviewHandle>}
isSharedConvo={isSharedConvo}
/>
</div>

View file

@ -0,0 +1,31 @@
import { buildMarkdownDoc } from './renderers/markdown-renderer';
import { buildMermaidDoc } from './renderers/mermaid-renderer';
import { buildReactDoc } from './renderers/react-renderer';
import { buildHtmlDoc } from './renderers/html-renderer';
import { buildSvgDoc } from './renderers/svg-renderer';
import { isMermaidFile, isHtmlFile, isMarkdownFile, isSvgFile } from './helpers';
export interface RenderConfig {
fileName: string;
code: string;
files: Record<string, string>;
isDarkMode?: boolean;
}
type Renderer = {
id: 'html' | 'svg' | 'mermaid' | 'markdown' | 'react';
test: (cfg: RenderConfig) => boolean;
render: (cfg: RenderConfig) => string;
};
const RENDERERS: Renderer[] = [
{ id: 'html', test: ({ fileName }) => isHtmlFile(fileName), render: ({ code, isDarkMode }) => buildHtmlDoc(code, !!isDarkMode) },
{ id: 'svg', test: ({ fileName, code }) => isSvgFile(fileName, code), render: ({ code, isDarkMode }) => buildSvgDoc(code, !!isDarkMode) },
{ id: 'mermaid', test: ({ fileName, code }) => isMermaidFile(fileName, code), render: ({ code, isDarkMode }) => buildMermaidDoc(code, !!isDarkMode) },
{ id: 'markdown', test: ({ fileName }) => isMarkdownFile(fileName), render: ({ code, isDarkMode }) => buildMarkdownDoc(code, !!isDarkMode) },
{ id: 'react', test: () => true, render: (cfg) => buildReactDoc(cfg) },
];
export function buildArtifactHtml(config: RenderConfig): string {
return (RENDERERS.find(r => r.test(config)) ?? RENDERERS[RENDERERS.length - 1]).render(config);
}

View file

@ -0,0 +1,99 @@
// client/src/utils/artifacts/core.ts
export const DEPENDENCY_VERSIONS: Record<string, string> = {
react: "19.2.4",
"react-dom": "19.2.4",
// Rendering
"react-markdown": "10.1.0",
mermaid: "11.12.2",
// App/UI ecosystem
three: "0.167.1",
"lucide-react": "0.394.0",
"react-router-dom": "6.11.2",
"class-variance-authority": "0.6.0",
clsx: "1.2.1",
"date-fns": "3.3.1",
"tailwind-merge": "1.9.1",
"tailwindcss-animate": "1.0.5",
recharts: "2.12.7",
lodash: "4.17.21",
"framer-motion": "11.0.8",
"embla-carousel-react": "8.2.0",
"react-day-picker": "9.0.8",
"dat.gui": "0.7.9",
cmdk: "1.0.0",
vaul: "0.9.1",
sonner: "1.4.0",
// Radix
"@radix-ui/react-accordion": "1.1.2",
"@radix-ui/react-alert-dialog": "1.0.2",
"@radix-ui/react-aspect-ratio": "1.1.0",
"@radix-ui/react-avatar": "1.1.0",
"@radix-ui/react-checkbox": "1.0.3",
"@radix-ui/react-collapsible": "1.0.3",
"@radix-ui/react-dialog": "1.0.2",
"@radix-ui/react-dropdown-menu": "2.1.1",
"@radix-ui/react-hover-card": "1.0.5",
"@radix-ui/react-label": "2.0.0",
"@radix-ui/react-menubar": "1.1.1",
"@radix-ui/react-navigation-menu": "1.2.0",
"@radix-ui/react-popover": "1.0.7",
"@radix-ui/react-progress": "1.1.0",
"@radix-ui/react-radio-group": "1.1.3",
"@radix-ui/react-select": "2.0.0",
"@radix-ui/react-separator": "1.0.3",
"@radix-ui/react-slider": "1.1.1",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-switch": "1.0.3",
"@radix-ui/react-tabs": "1.0.3",
"@radix-ui/react-toast": "1.1.5",
"@radix-ui/react-toggle": "1.1.0",
"@radix-ui/react-toggle-group": "1.1.0",
"@radix-ui/react-tooltip": "1.2.8",
};
const NEEDS_REACT_PEER_PREFIXES = ["recharts", "framer-motion", "react-router", "react-router-dom", "lucide-react",
"@radix-ui/", "cmdk", "embla-carousel-react", "vaul", "sonner", "react-day-picker",];
function withVersion(pkg: string) {
return DEPENDENCY_VERSIONS[pkg] ?? "latest";
}
export function resolveNpmUrl(pkg: string): string {
const version = withVersion(pkg);
let url = `https://esm.sh/${pkg}@${version}?dev`;
if (NEEDS_REACT_PEER_PREFIXES.some((p) => pkg.startsWith(p))) {
url += `&external=react,react-dom`;
}
// helps avoid export edge cases
if (pkg === "lucide-react") {
url += "&bundle";
}
return url;
}
export function buildImportMap(npmImports: Set<string>) {
const react = DEPENDENCY_VERSIONS.react;
const reactDom = DEPENDENCY_VERSIONS["react-dom"];
const imports: Record<string, string> = {
react: `https://esm.sh/react@${react}?dev`,
"react/": `https://esm.sh/react@${react}/`,
"react-dom": `https://esm.sh/react-dom@${reactDom}?dev`,
"react-dom/": `https://esm.sh/react-dom@${reactDom}/`,
"react-dom/client": `https://esm.sh/react-dom@${reactDom}/client?dev`,
"react/jsx-runtime": `https://esm.sh/react@${react}/jsx-runtime`,
"react/jsx-dev-runtime": `https://esm.sh/react@${react}/jsx-dev-runtime`,
};
for (const pkg of npmImports) {
if (!imports[pkg]) imports[pkg] = resolveNpmUrl(pkg);
}
return imports;
}

View file

@ -0,0 +1,246 @@
import type { ArtifactFiles } from '~/common';
const IMPORT_REGEX = /import\s+(?:[\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g;
export function isMarkdownFile(name: string) {
return name.endsWith('.md') || name.endsWith('.markdown');
}
export function isHtmlFile(name: string) {
return name.endsWith('.html');
}
export function isSvgFile(fileName: string, code = '') {
if (fileName.endsWith('.svg')) return true;
return code.trim().startsWith('<svg') || code.trim().startsWith('<?xml');
}
export function isMermaidFile(fileName: string, code: string) {
if (fileName.endsWith('.mermaid') || fileName.endsWith('.mmd')) return true;
const clean = code.trim().toLowerCase();
const keywords = [
'graph ', 'flowchart ', 'sequencediagram', 'classdiagram',
'statediagram', 'erdiagram', 'gantt', 'pie ', 'gitgraph',
'journey', 'mindmap', 'timeline', 'quadrantchart',
'requirementdiagram', 'c4context', 'c4container'
];
return keywords.some(k =>
clean.startsWith(k) ||
clean.startsWith('```mermaid') ||
clean.includes('```mermaid\n' + k) ||
clean.includes('```\n' + k)
);
}
export function extractNpmImports(code: string): Set<string> {
const set = new Set<string>();
let match;
IMPORT_REGEX.lastIndex = 0;
while ((match = IMPORT_REGEX.exec(code)) !== null) {
const pkg = match[1];
if (!pkg.startsWith('.') && !pkg.startsWith('/') && !pkg.startsWith('@/')) set.add(pkg);
}
return set;
}
export function scanVirtualDependencies(
code: string,
availableFiles: Record<string, string>,
found: Set<string> = new Set()
): Set<string> {
let match;
IMPORT_REGEX.lastIndex = 0;
while ((match = IMPORT_REGEX.exec(code)) !== null) {
const importPath = match[1];
const candidates = [
importPath,
importPath.replace('@/', '/'),
importPath + '.tsx',
importPath.replace('@/', '/') + '.tsx',
importPath + '.ts',
importPath.replace('@/', '/') + '.ts'
];
const matchedKey = candidates.find(key => availableFiles[key] !== undefined);
if (matchedKey && !found.has(matchedKey)) {
found.add(matchedKey);
scanVirtualDependencies(availableFiles[matchedKey], availableFiles, found);
}
}
return found;
}
export function cleanMermaid(code: string): string {
let clean = code
.replace(/^```mermaid\s*/gim, '')
.replace(/^```\s*/gm, '')
.replace(/```\s*$/gm, '')
.trim();
clean = clean
.split('\n')
.map(line => line.replace(/--!?>\s*$/, '').replace(/->\s*$/, '').replace(/---\s*$/, '').replace(/--\s*$/, ''))
.filter(line => line.trim() !== '')
.join('\n')
.replace(/--\s+>/g, '-->')
.replace(/-\s+->/g, '-->')
.replace(/\s+---\s+/g, ' --- ')
.replace(/\r\n/g, '\n')
.replace(/\n\s*\n\s*\n/g, '\n\n')
.trim();
const first = clean.split('\n')[0]?.toLowerCase() || '';
const keywords = [
'graph','flowchart','sequencediagram','classdiagram','statediagram',
'erdiagram','gantt','pie','gitgraph','journey','mindmap','timeline'
];
if (!keywords.some(k => first.startsWith(k)) && clean) {
clean = clean.includes('participant') ? 'sequenceDiagram\n' + clean
: clean.includes('class ') ? 'classDiagram\n' + clean
: 'flowchart TD\n' + clean;
}
return clean;
}
export function sanitizeSvg(input: string): string {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(input, 'image/svg+xml');
const parserError = doc.querySelector('parsererror');
if (parserError) {
return '';
}
const blockedTags = new Set([
'script',
'foreignobject',
]);
const urlAttrs = new Set([
'href',
'xlink:href',
]);
const isSafeUrl = (value: string) => {
const v = value.trim().toLowerCase();
if (
v.startsWith('#') ||
v.startsWith('/') ||
v.startsWith('./') ||
v.startsWith('../') ||
v.startsWith('http://') ||
v.startsWith('https://') ||
v.startsWith('data:image/')
) {
return true;
}
return false;
};
const walk = (el: Element) => {
const tag = el.tagName.toLowerCase();
if (blockedTags.has(tag)) {
el.remove();
return;
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const value = attr.value;
if (name.startsWith('on')) {
el.removeAttribute(attr.name);
continue;
}
if (urlAttrs.has(name) && !isSafeUrl(value)) {
el.removeAttribute(attr.name);
continue;
}
}
for (const child of Array.from(el.children)) {
walk(child);
}
};
const root = doc.documentElement;
if (!root || root.tagName.toLowerCase() !== 'svg') {
return '';
}
walk(root);
return new XMLSerializer().serializeToString(root);
} catch {
return '';
}
}
type FileLike = string | { code?: string; content?: string };
/**
* Builds normalized runtime file map.
* - Optionally injects shared files (React only)
* - Excludes html shell files from module graph
* - Artifact files override shared files
*/
export function buildRuntimeFileMap({
files,
sharedFiles = {},
includeShared = false,
}: {
files?: ArtifactFiles | Record<string, FileLike>;
sharedFiles?: Record<string, string>;
includeShared?: boolean;
}): Record<string, string> {
const fileMap: Record<string, string> = {};
if (includeShared) {
Object.entries(sharedFiles).forEach(([path, content]) => {
const normalized = normalizeArtifactPath(path);
// prevent HTML docs entering JS module graph
if (normalized === '/public/index.html' || normalized.endsWith('.html')) return;
if (typeof content === 'string' && content.length > 0) {
fileMap[normalized] = content;
}
});
}
Object.entries(files ?? {}).forEach(([path, fileObj]) => {
const content = extractFileContent(fileObj);
if (!content) return;
fileMap[normalizeArtifactPath(path)] = content;
});
return fileMap;
}
export function normalizeArtifactPath(path: string): string {
return path.startsWith('/') ? path : `/${path}`;
}
export function extractFileContent(fileObj: FileLike | undefined): string {
if (!fileObj) return '';
if (typeof fileObj === 'string') return fileObj;
return fileObj.code ?? fileObj.content ?? '';
}
export type ArtifactKind = 'html' | 'svg' | 'mermaid' | 'markdown' | 'react';
export function resolveArtifactKind(fileName: string, code: string): ArtifactKind {
if (isHtmlFile(fileName)) return 'html';
if (isSvgFile(fileName, code)) return 'svg';
if (isMermaidFile(fileName, code)) return 'mermaid';
if (isMarkdownFile(fileName)) return 'markdown';
return 'react';
}

View file

@ -0,0 +1,2 @@
export { buildArtifactHtml } from './artifact-builder';
export type { RenderConfig } from './artifact-builder';

View file

@ -0,0 +1,42 @@
import { baseHtml, linkHandlerScript } from '../templates';
export function buildHtmlDoc(code: string, isDarkMode: boolean): string {
const hasFullDoc = /<html[\s>]/i.test(code) || /<body[\s>]/i.test(code);
if (hasFullDoc) {
const readyScript = `
<script>
window.addEventListener('load', () => {
window.parent.postMessage({ type: 'artifact-ready' }, '*');
});
</script>
`;
if (/<\/body>/i.test(code)) {
return code.replace(/<\/body>/i, `${readyScript}${linkHandlerScript()}</body>`);
}
return code + readyScript + linkHandlerScript();
}
return baseHtml({
isDarkMode,
head: `
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<script>
tailwind.config = { darkMode: 'class' };
window.onload = () => window.parent.postMessage({ type: 'artifact-ready' }, '*');
</script>
<style>
body {
margin: 0;
padding: 0;
background-color: ${isDarkMode ? '#030712' : '#ffffff'};
color-scheme: ${isDarkMode ? 'dark' : 'light'};
font-family: system-ui, sans-serif;
}
</style>
`,
body: code
});
}

View file

@ -0,0 +1,63 @@
import { baseHtml } from '../templates';
import { DEPENDENCY_VERSIONS } from '../core';
export function buildMarkdownDoc(code: string, isDarkMode: boolean): string {
const markdown = JSON.stringify(code || '');
const head = `
<style>
body {
margin: 0;
padding: 24px;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: ${isDarkMode ? '#0b0f19' : '#ffffff'};
color: ${isDarkMode ? '#e5e7eb' : '#111827'};
}
.markdown {
max-width: 900px;
margin: 0 auto;
line-height: 1.6;
}
.markdown h1, .markdown h2, .markdown h3 {
margin: 1.2em 0 0.5em;
}
.markdown pre {
background: ${isDarkMode ? '#111827' : '#f3f4f6'};
padding: 12px;
border-radius: 8px;
overflow-x: auto;
}
.markdown code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.markdown a { color: ${isDarkMode ? '#60a5fa' : '#2563eb'}; }
.markdown blockquote {
border-left: 4px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
margin: 1em 0;
padding: 0.5em 1em;
color-scheme: ${isDarkMode ? 'dark' : 'light'};
background: ${isDarkMode ? '#111827' : '#f9fafb'};
}
</style>
`;
const body = `
<div id="root" class="markdown"></div>
<script type="module">
import React from "https://esm.sh/react@${DEPENDENCY_VERSIONS.react}?dev";
import { createRoot } from "https://esm.sh/react-dom@${DEPENDENCY_VERSIONS["react-dom"]}/client?dev";
import ReactMarkdown from "https://esm.sh/react-markdown@${DEPENDENCY_VERSIONS["react-markdown"]}?dev";
const md = ${markdown};
const App = () => React.createElement(
ReactMarkdown,
{ children: md }
);
createRoot(document.getElementById("root")).render(
React.createElement(App)
);
window.parent.postMessage({ type: 'artifact-ready' }, '*');
</script>
`;
return baseHtml({ head, body, isDarkMode });
}

View file

@ -0,0 +1,175 @@
import { DEPENDENCY_VERSIONS } from '../core';
import { cleanMermaid } from '../helpers';
export function buildMermaidDoc(code: string, isDarkMode: boolean): string {
const cleanCode = cleanMermaid(code)
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$');
return `<!DOCTYPE html>
<base target="_self">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background-color: ${isDarkMode ? '#0d1117' : '#ffffff'};
color: ${isDarkMode ? '#e6edf3' : '#1f2328'};
margin: 0;
padding: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
position: relative;
width: 100vw;
height: 100vh;
}
#mermaid-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden; /* Changed from auto to hidden for custom pan/zoom */
cursor: grab;
position: relative;
}
#mermaid-container.grabbing { cursor: grabbing; }
.mermaid-wrapper { transform-origin: center center; transition: transform 0.1s ease-out; }
.mermaid { display: inline-block; background: transparent; }
/* Controls */
.zoom-controls {
position: fixed; bottom: 20px; right: 20px;
display: flex; flex-direction: column; gap: 8px;
z-index: 1000;
background: ${isDarkMode ? 'rgba(30,30,30,0.95)' : 'rgba(255,255,255,0.95)'};
border: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
border-radius: 8px; padding: 8px;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
}
.zoom-btn {
width: 36px; height: 36px; border: none;
background: ${isDarkMode ? '#374151' : '#f3f4f6'};
color: ${isDarkMode ? '#e6edf3' : '#1f2328'};
border-radius: 6px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: bold; transition: background 0.2s;
}
.zoom-btn:hover { background: ${isDarkMode ? '#4b5563' : '#e5e7eb'}; }
.zoom-level { text-align:center; font-size:12px; color:${isDarkMode ? '#9ca3af' : '#6b7280'}; padding:4px 0; font-weight:500; }
.error-display {
background:${isDarkMode ? '#1c1917' : '#fef2f2'};
border:2px solid ${isDarkMode ? '#991b1b' : '#ef4444'};
border-radius:8px; padding:20px; max-width:600px;
color:${isDarkMode ? '#fca5a5' : '#991b1b'};
margin:20px;
z-index: 2000;
}
</style>
</head>
<body>
<div id="mermaid-container">
<div class="mermaid-wrapper">
<div class="mermaid"></div>
</div>
</div>
<div class="zoom-controls">
<button class="zoom-btn" id="zoom-in">+</button>
<div class="zoom-level" id="zoom-level">100%</div>
<button class="zoom-btn" id="zoom-out"></button>
<button class="zoom-btn reset-btn" id="reset"></button>
</div>
<script type="module">
import mermaid from "https://esm.sh/mermaid@${DEPENDENCY_VERSIONS.mermaid}";
window.parent.postMessage({ type: 'progress', message: 'Initializing Mermaid...' }, '*');
mermaid.initialize({
startOnLoad: false,
theme: '${isDarkMode ? 'dark' : 'default'}',
securityLevel: 'strict',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
logLevel: 'error'
});
const graphDefinition = \`${cleanCode}\`;
const container = document.getElementById('mermaid-container');
const wrapper = document.querySelector('.mermaid-wrapper');
let scale = 1;
let translateX = 0;
let translateY = 0;
function updateTransform() {
wrapper.style.transform = \`translate(\${translateX}px,\${translateY}px) scale(\${scale})\`;
document.getElementById('zoom-level').textContent = \`\${Math.round(scale*100)}%\`;
}
const zoomIn = () => { scale = Math.min(scale * 1.2, 5); updateTransform(); };
const zoomOut = () => { scale = Math.max(scale / 1.2, 0.1); updateTransform(); };
const resetView = () => { scale = 1; translateX = 0; translateY = 0; updateTransform(); };
document.getElementById('zoom-in').onclick = zoomIn;
document.getElementById('zoom-out').onclick = zoomOut;
document.getElementById('reset').onclick = resetView;
// Pan Logic
let isPanning = false;
let startX = 0, startY = 0;
container.addEventListener('mousedown', (e) => {
if (e.target.closest('.zoom-controls')) return;
isPanning = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
container.classList.add('grabbing');
});
window.addEventListener('mousemove', (e) => {
if (!isPanning) return;
e.preventDefault();
translateX = e.clientX - startX;
translateY = e.clientY - startY;
updateTransform();
});
window.addEventListener('mouseup', () => {
isPanning = false;
container.classList.remove('grabbing');
});
// Wheel Zoom
container.addEventListener('wheel', (e) => {
e.preventDefault();
if (e.deltaY < 0) zoomIn();
else zoomOut();
}, { passive: false });
async function renderDiagram() {
try {
const el = document.querySelector('.mermaid');
el.textContent = graphDefinition;
await mermaid.run({ nodes: [el] });
// Better Scaling Logic
setTimeout(() => {
const svg = el.querySelector('svg');
if (svg) {
const bbox = svg.getBBox();
const containerW = container.clientWidth;
const containerH = container.clientHeight;
// Only shrink if it's actually bigger than the screen
if (bbox.width > containerW || bbox.height > containerH) {
const s = Math.min(containerW / (bbox.width + 40), containerH / (bbox.height + 40));
scale = Math.max(s, 0.1); // Don't go microscopic
} else {
scale = 1;
}
updateTransform();
}
window.parent.postMessage({ type: 'artifact-ready' }, '*');
}, 50);
} catch (e) {
container.innerHTML = \`<div class="error-display"><h3>Render Error</h3><pre>\${e.message}</pre></div>\`;
window.parent.postMessage({ type: 'artifact-error', error: e.message }, '*');
}
}
renderDiagram();
</script>
</body>
</html>`;
}

View file

@ -0,0 +1,40 @@
import { baseHtml } from '../templates';
import { getReactRunnerScriptTag } from './react-runner';
import { DEPENDENCY_VERSIONS } from '../core';
import type { RenderConfig } from '../artifact-builder';
export function buildReactDoc({ isDarkMode }: RenderConfig): string {
// Static map ensures core deps are available immediately
const coreMap = {
imports: {
"react": `https://esm.sh/react@${DEPENDENCY_VERSIONS.react}?dev`,
"react-dom": `https://esm.sh/react-dom@${DEPENDENCY_VERSIONS["react-dom"]}?dev`,
"react-dom/client": `https://esm.sh/react-dom@${DEPENDENCY_VERSIONS["react-dom"]}/client?dev`
}
};
const head = `
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<script>tailwind.config = { darkMode: 'class' };</script>
<!-- Core Import Map (Static) -->
<script type="importmap-shim">
${JSON.stringify(coreMap)}
</script>
<script src="https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.2/babel.min.js"></script>
<style>
body { background-color: ${isDarkMode ? '#030712' : '#ffffff'}; color-scheme: ${isDarkMode ? 'dark' : 'light'}; }
#root { min-height: 100vh; }
</style>
`;
const body = `
<div id="root"></div>
${getReactRunnerScriptTag()}
`;
return baseHtml({ head, body, isDarkMode });
}

View file

@ -0,0 +1,632 @@
// react-runner.ts
export function reactRunnerMain() {
const BLOCKED_PROP_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
const isSafePropName = (name: string) =>
/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !BLOCKED_PROP_NAMES.has(name);
const isSafeImportName = (name: string) => isSafePropName(name);
const isSafeRelativeLike = (value: string) =>
value.startsWith('./') || value.startsWith('../');
const isSafeVirtualPath = (value: string) =>
value.startsWith('/') || value.startsWith('@/') || isSafeRelativeLike(value);
const resolveRelativeImport = (fromPath: string, importPath: string): string | null => {
if (!isSafeRelativeLike(importPath)) return null;
const baseDir = fromPath.slice(0, fromPath.lastIndexOf('/'));
const stack = baseDir.split('/').filter(Boolean);
for (const part of importPath.split('/')) {
if (!part || part === '.') continue;
if (part === '..') {
if (stack.length === 0) return null;
stack.pop();
continue;
}
stack.push(part);
}
return '/' + stack.join('/');
};
const w = window as any;
const normalizePath = (path: string) =>
path.replace(/^\.\//, '').replace(/^@\//, '/').replace(/^\/\//, '/');
const stripExtension = (path: string) =>
path.replace(/\.(tsx|ts|jsx|js|css)$/, '');
function buildPathRegistry(files: Record<string, string>) {
const registry = new Map<string, string>();
Object.keys(files).forEach((actualPath) => {
const normalized = normalizePath(actualPath);
const stripped = stripExtension(normalized);
const variants = [
actualPath,
normalized,
stripped,
'/' + normalized,
'/' + stripped,
'@/' + normalized.replace(/^\//, ''),
'@/' + stripped.replace(/^\/+/, ''),
];
variants.forEach((v) => {
if (!registry.has(v)) registry.set(v, actualPath);
});
});
return registry;
}
let activeBlobUrls: string[] = [];
let activeStyleNodes: HTMLElement[] = [];
let activeRoot: any = null;
let activeRenderToken = 0;
let activeMountEl: HTMLElement | null = null;
const runnerCleanup = {
intervals: new Set<number>(),
timeouts: new Set<number>(),
rafs: new Set<number>(),
listeners: [] as Array<{
target: EventTarget;
type: string;
listener: EventListenerOrEventListenerObject;
options?: boolean | AddEventListenerOptions;
}>,
};
const resetRunnerCleanupState = () => {
runnerCleanup.intervals.forEach((id) => {
try {
clearInterval(id);
} catch { }
});
runnerCleanup.intervals.clear();
runnerCleanup.timeouts.forEach((id) => {
try {
clearTimeout(id);
} catch { }
});
runnerCleanup.timeouts.clear();
runnerCleanup.rafs.forEach((id) => {
try {
cancelAnimationFrame(id);
} catch { }
});
runnerCleanup.rafs.clear();
runnerCleanup.listeners.forEach(({ target, type, listener, options }) => {
try {
target.removeEventListener(type, listener, options);
} catch { }
});
runnerCleanup.listeners = [];
};
const cleanupPreviousRender = () => {
activeRenderToken++;
if (activeRoot) {
try {
activeRoot.unmount();
} catch { }
activeRoot = null;
}
if (activeMountEl) {
try {
activeMountEl.remove();
} catch { }
activeMountEl = null;
}
activeStyleNodes.forEach((node) => {
try {
node.remove();
} catch { }
});
activeStyleNodes = [];
activeBlobUrls.forEach((url) => {
try {
URL.revokeObjectURL(url);
} catch { }
});
activeBlobUrls = [];
resetRunnerCleanupState();
delete w.React;
delete w.__artifactStyleNodes;
};
const trackBlobUrl = (url: string) => {
activeBlobUrls.push(url);
return url;
};
// Optional runner-level tracking hooks. These only affect code executed
// inside this iframe window and allow cleanup if future runner code uses them.
// User React code can also benefit if it touches window-level APIs directly.
const originalSetTimeout = w.setTimeout.bind(window);
const originalSetInterval = w.setInterval.bind(window);
const originalRequestAnimationFrame = w.requestAnimationFrame.bind(window);
const originalAddEventListener = EventTarget.prototype.addEventListener;
const originalRemoveEventListener = EventTarget.prototype.removeEventListener;
if (!w.__artifactPatchedGlobals) {
w.__artifactPatchedGlobals = true;
w.setTimeout = function (handler: TimerHandler, timeout?: number, ...args: any[]) {
const id = originalSetTimeout(function (...cbArgs: any[]) {
runnerCleanup.timeouts.delete(id as number);
if (typeof handler === 'function') {
return (handler as any)(...cbArgs);
}
return eval(handler as string);
}, timeout, ...args) as unknown as number;
runnerCleanup.timeouts.add(id);
return id;
};
w.clearTimeout = function (id: number) {
runnerCleanup.timeouts.delete(id);
return clearTimeout(id);
};
w.setInterval = function (handler: TimerHandler, timeout?: number, ...args: any[]) {
const id = originalSetInterval(handler, timeout, ...args) as unknown as number;
runnerCleanup.intervals.add(id);
return id;
};
w.clearInterval = function (id: number) {
runnerCleanup.intervals.delete(id);
return clearInterval(id);
};
w.requestAnimationFrame = function (cb: FrameRequestCallback) {
const id = originalRequestAnimationFrame(function (ts: number) {
runnerCleanup.rafs.delete(id);
cb(ts);
}) as unknown as number;
runnerCleanup.rafs.add(id);
return id;
};
w.cancelAnimationFrame = function (id: number) {
runnerCleanup.rafs.delete(id);
return cancelAnimationFrame(id);
};
EventTarget.prototype.addEventListener = function (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
) {
const target = this as EventTarget;
if (
target === window ||
target === document ||
target === document.body ||
target === document.documentElement
) {
runnerCleanup.listeners.push({ target, type, listener, options });
}
return originalAddEventListener.call(target, type, listener, options);
};
EventTarget.prototype.removeEventListener = function (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions
) {
const target = this as EventTarget;
runnerCleanup.listeners = runnerCleanup.listeners.filter(
(x) =>
!(
x.target === target &&
x.type === type &&
x.listener === listener &&
x.options === options
)
);
return originalRemoveEventListener.call(target, type, listener, options);
};
}
w.__getSafeComponent = (comp: any, name: string, mod: any) => {
if (!isSafePropName(name) && name !== 'default' && name !== '*') {
return () => null;
}
if (comp === undefined && name !== 'default' && name !== '*' && mod && Object.prototype.hasOwnProperty.call(mod, name)) {
return mod[name];
}
if (
comp === undefined &&
name !== 'default' &&
name !== '*' &&
mod?.default &&
typeof mod.default === 'object' &&
Object.prototype.hasOwnProperty.call(mod.default, name)
) {
return mod.default[name];
}
if (comp !== undefined && comp !== null) return comp;
console.warn(`Artifact: Missing export "${name}"`);
return () => {
const React = w.React;
if (!React) return null;
return React.createElement('div', null, `⚠️ ${name}`);
};
};
async function renderReact(payload: any) {
cleanupPreviousRender();
const renderToken = activeRenderToken;
const { runId, entryKey, files, npmImportMap, isDarkMode } = payload;
const post = (type: string, extra: Record<string, unknown> = {}) => {
window.parent.postMessage({ type, runId, ...extra }, '*');
};
const ensureCurrent = () => {
if (renderToken !== activeRenderToken) {
throw new Error('Stale render aborted.');
}
};
post('progress', { message: 'Booting runtime...' });
document.documentElement.classList.toggle('dark', !!isDarkMode);
const rootHost = document.getElementById('root');
if (!rootHost) {
post('artifact-error', { error: 'Root element #root not found.' });
return;
}
rootHost.innerHTML = '';
const mountEl = document.createElement('div');
mountEl.id = 'artifact-react-mount';
rootHost.appendChild(mountEl);
activeMountEl = mountEl;
post('progress', { message: 'Indexing files...' });
const pathRegistry = buildPathRegistry(files);
const blobs: Record<string, string> = {};
w.__artifactStyleNodes = activeStyleNodes;
const emptyModuleBlob = trackBlobUrl(
URL.createObjectURL(
new Blob(
[
`
import React from 'react';
const cn = (...args) => args.filter(Boolean).join(' ');
export const Alert = ({ children, className }) => React.createElement('div', { className: cn("relative w-full rounded-lg border p-4 shadow-sm bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100", className) }, children);
export const AlertTitle = ({ children, className }) => React.createElement('h5', { className: cn("mb-1 font-semibold leading-none tracking-tight", className) }, children);
export const AlertDescription = ({ children, className }) => React.createElement('div', { className: cn("text-sm opacity-90", className) }, children);
export default {};
`,
],
{ type: 'text/javascript' }
)
)
);
const createCssModule = (code: string, isExternal = false) => {
const jsCode = isExternal
? `
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = ${JSON.stringify(code)};
document.head.appendChild(link);
(window.__artifactStyleNodes || (window.__artifactStyleNodes = [])).push(link);
export default {};
`
: `
const style = document.createElement('style');
style.textContent = ${JSON.stringify(code)};
document.head.appendChild(style);
(window.__artifactStyleNodes || (window.__artifactStyleNodes = [])).push(style);
export default {};
`;
return trackBlobUrl(
URL.createObjectURL(new Blob([jsCode], { type: 'text/javascript' }))
);
};
try {
post('progress', { message: 'Preparing CSS...' });
Object.keys(files).forEach((path) => {
if (path.endsWith('.css')) {
blobs[path] = createCssModule(files[path]);
} else {
blobs[path] = 'placeholder';
}
});
const transformFile = (path: string, code: string) => {
const { code: compiled } = w.Babel.transform(code, {
filename: path,
presets: [
['env', { modules: false }],
['react', { runtime: 'automatic' }],
'typescript',
],
plugins: [
function ({ types: t }: any) {
return {
visitor: {
ImportDeclaration(p: any) {
const srcNode = p.node.source;
const src = typeof srcNode?.value === 'string' ? srcNode.value : '';
if (!src) {
throw new Error(`Invalid import source in ${path}`);
}
// Let core/runtime packages resolve through import maps.
if (['react', 'react-dom', 'react-dom/client'].includes(src)) return;
const specifiers = Array.isArray(p.node.specifiers) ? p.node.specifiers : [];
// Keep namespace imports as-is unless we need to remap local virtual files.
const hasNamespaceSpecifier = specifiers.some((s: any) =>
t.isImportNamespaceSpecifier(s)
);
let actualPath: string | undefined;
let finalSource: string | undefined;
if (src.startsWith('.')) {
const resolved = resolveRelativeImport(path, src);
if (resolved) {
actualPath = pathRegistry.get(resolved);
}
} else if (src.startsWith('/') || src.startsWith('@/')) {
if (!isSafeVirtualPath(src)) {
throw new Error(`Unsafe virtual import path: ${src}`);
}
actualPath = pathRegistry.get(src);
}
if (actualPath && Object.prototype.hasOwnProperty.call(blobs, actualPath)) {
finalSource = blobs[actualPath];
} else if (src.endsWith('.css')) {
if (src.includes('react-day-picker')) {
finalSource = createCssModule(
'https://unpkg.com/react-day-picker/dist/style.css',
true
);
} else if (src.startsWith('.') || src.startsWith('/') || src.startsWith('@/')) {
// Local CSS import that could not be resolved: replace with inert module.
finalSource = trackBlobUrl(
URL.createObjectURL(
new Blob(['export default {}'], { type: 'text/javascript' })
)
);
} else {
// Non-local CSS imports should resolve via import map / runtime if supported.
return;
}
} else if (src.startsWith('.') || src.startsWith('/') || src.startsWith('@/')) {
// Unknown local module: fail closed to inert module instead of leaving arbitrary specifier.
finalSource = emptyModuleBlob;
} else {
// Bare package import: leave for import map resolution.
return;
}
p.node.source = t.stringLiteral(finalSource);
if (hasNamespaceSpecifier) {
return;
}
if (specifiers.length === 0) {
return;
}
const nsId = p.scope.generateUidIdentifier('ns');
const newImport = t.importDeclaration(
[t.importNamespaceSpecifier(nsId)],
t.stringLiteral(finalSource)
);
const vars = specifiers.flatMap((spec: any) => {
if (t.isImportNamespaceSpecifier(spec)) {
return [];
}
const localName = spec?.local?.name;
if (!isSafePropName(localName)) {
throw new Error(`Unsafe local import binding: ${String(localName)}`);
}
let accessor;
let importNameStr: string;
if (t.isImportDefaultSpecifier(spec)) {
accessor = t.memberExpression(nsId, t.identifier('default'));
importNameStr = 'default';
} else if (t.isImportSpecifier(spec)) {
const imported = spec.imported;
let importedName: string | null = null;
if (t.isIdentifier(imported)) {
importedName = imported.name;
} else if (t.isStringLiteral(imported)) {
importedName = imported.value;
}
if (!importedName || !isSafeImportName(importedName)) {
throw new Error(`Unsafe named import: ${String(importedName)}`);
}
accessor = t.memberExpression(nsId, t.stringLiteral(importedName), true);
importNameStr = importedName;
} else {
throw new Error(`Unsupported import specifier in ${path}`);
}
return t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier(localName),
t.callExpression(
t.memberExpression(
t.identifier('window'),
t.identifier('__getSafeComponent')
),
[accessor, t.stringLiteral(importNameStr), nsId]
)
),
]);
});
p.replaceWithMultiple([newImport, ...vars]);
},
},
};
},
],
});
return trackBlobUrl(
URL.createObjectURL(new Blob([compiled], { type: 'text/javascript' }))
);
};
post('progress', { message: `Compiling ${Object.keys(files).length} files...` });
Object.keys(files).forEach((path) => {
if (!path.endsWith('.css')) {
blobs[path] = transformFile(path, files[path]);
}
});
ensureCurrent();
post('progress', { message: 'Building import map...' });
const resolvedEntry = blobs[entryKey] || blobs[pathRegistry.get(entryKey) as string];
if (!resolvedEntry) throw new Error(`Entry file not found: ${entryKey}`);
const importMap = {
imports: {
...npmImportMap,
'entry-point': resolvedEntry,
'react/jsx-runtime':
npmImportMap['react/jsx-runtime'] || 'https://esm.sh/react@19.2.4/jsx-runtime',
'react/jsx-dev-runtime':
npmImportMap['react/jsx-dev-runtime'] || 'https://esm.sh/react@19.2.4/jsx-dev-runtime',
},
};
const oldDynamic = document.getElementById('dynamic-import-map');
if (oldDynamic) oldDynamic.remove();
const mapScript = document.createElement('script');
mapScript.type = 'importmap-shim';
mapScript.id = 'dynamic-import-map';
mapScript.textContent = JSON.stringify(importMap);
document.head.appendChild(mapScript);
if (w.importShim?.addImportMap) {
w.importShim.addImportMap(importMap);
}
await Promise.resolve();
ensureCurrent();
post('progress', { message: 'Loading React runtime...' });
const { createRoot } = await w.importShim('react-dom/client');
ensureCurrent();
const React = await w.importShim('react');
ensureCurrent();
w.React = React;
post('progress', { message: 'Importing entry module...' });
const mod = await w.importShim('entry-point');
ensureCurrent();
const Component =
mod.default ||
mod.App ||
Object.values(mod).find((exp: any) => typeof exp === 'function' && /^[A-Z]/.test(exp.name));
if (!Component) throw new Error('No default export found.');
post('progress', { message: 'Rendering component...' });
activeRoot = createRoot(mountEl);
activeRoot.render(React.createElement(Component));
ensureCurrent();
post('artifact-ready');
} catch (err) {
if (String(err) === 'Error: Stale render aborted.') {
return;
}
console.error(err);
post('artifact-error', { error: String(err) });
}
}
window.addEventListener('beforeunload', cleanupPreviousRender);
window.addEventListener('message', (e) => {
try {
// In sandboxed srcdoc, origin may be "null". Source check is the reliable boundary.
if (e.source !== window.parent) return;
const data = e.data;
if (!data || typeof data !== 'object') return;
if (data.type !== 'render') return;
if (!data.payload || typeof data.payload !== 'object') return;
renderReact(data.payload);
} catch (err) {
try {
window.parent.postMessage({ type: 'artifact-error', error: String(err) }, '*');
} catch { }
}
});
window.addEventListener('error', (event) => {
try {
window.parent.postMessage({ type: 'artifact-error', error: event?.error ? String(event.error) : String(event.message || 'Unknown iframe error') }, '*');
} catch { }
});
window.addEventListener('unhandledrejection', (event) => {
try {
window.parent.postMessage(
{
type: 'artifact-error',
error: event?.reason ? String(event.reason) : 'Unhandled promise rejection in artifact runner',
},
'*'
);
} catch { }
});
}
export function getReactRunnerScriptTag(): string {
return `<script type="module">;(${reactRunnerMain.toString()})();</script>`;
}

View file

@ -0,0 +1,37 @@
import { sanitizeSvg } from '../helpers';
import { baseHtml } from '../templates';
export function buildSvgDoc(code: string, isDarkMode: boolean) {
const trimmed = sanitizeSvg(code.trim());
const isFullSvg = trimmed.startsWith('<svg') || trimmed.startsWith('<?xml');
const content = isFullSvg
? trimmed
: `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">${trimmed}</svg>`;
const head = `
<style>
body {
margin: 0; padding: 0;
height: 100vh; width: 100vw;
display: flex; align-items: center; justify-content: center;
background-color: ${isDarkMode ? '#0f172a' : '#ffffff'};
color: ${isDarkMode ? '#e2e8f0' : '#1e293b'};
overflow: hidden;
}
svg {
max-width: 95%; max-height: 95%;
width: auto; height: auto;
}
</style>
`;
const body = `
${content}
<script>
window.onload = () => window.parent.postMessage({ type: 'artifact-ready' }, '*');
</script>
`;
return baseHtml({ head, body, isDarkMode });
}

View file

@ -0,0 +1,62 @@
export function linkHandlerScript() {
return `
<script>
(function(){
document.addEventListener('click', (e) => {
const a = e.target.closest('a');
if (!a) return;
const href = a.getAttribute('href');
const normalizedHref = (href || '').trim().toLowerCase();
if (!href || normalizedHref.startsWith('javascript:')) {
e.preventDefault();
return;
}
if (href.startsWith('#')) {
e.preventDefault();
try {
window.location.hash = href.slice(1);
} catch {}
return;
}
// Disable all non-hash navigation
e.preventDefault();
}, true);
})();
</script>
`;
}
const ARTIFACT_CSP = [
"default-src 'none'",
"base-uri 'none'",
"form-action 'none'",
"object-src 'none'",
"frame-src 'none'",
"script-src 'unsafe-inline' 'unsafe-eval' blob: https://esm.sh https://cdn.tailwindcss.com https://ga.jspm.io https://unpkg.com",
"connect-src blob: https://esm.sh https://cdn.tailwindcss.com https://ga.jspm.io https://unpkg.com",
"style-src 'unsafe-inline' https://cdn.tailwindcss.com",
"img-src data: blob: https:",
"font-src data: https:",
].join('; ');
export function baseHtml({ head = '', body = '', isDarkMode = false }) {
return `<!DOCTYPE html>
<base target="_self">
<html lang="en" class="${isDarkMode ? 'dark' : ''}">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="Content-Security-Policy" content="${ARTIFACT_CSP}" />
${head}
</head>
<body>
${body}
${linkHandlerScript()}
</body>
</html>`;
}