mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
Merge 237d0b4ed5 into 8ed0bcf5ca
This commit is contained in:
commit
137f3c8d6c
14 changed files with 1755 additions and 3 deletions
319
client/src/components/Artifacts/ArtifactRenderer.tsx
Normal file
319
client/src/components/Artifacts/ArtifactRenderer.tsx
Normal 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;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
31
client/src/utils/artifacts/artifact-builder.ts
Normal file
31
client/src/utils/artifacts/artifact-builder.ts
Normal 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);
|
||||
}
|
||||
99
client/src/utils/artifacts/core.ts
Normal file
99
client/src/utils/artifacts/core.ts
Normal 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;
|
||||
}
|
||||
246
client/src/utils/artifacts/helpers.ts
Normal file
246
client/src/utils/artifacts/helpers.ts
Normal 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';
|
||||
}
|
||||
2
client/src/utils/artifacts/index.ts
Normal file
2
client/src/utils/artifacts/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { buildArtifactHtml } from './artifact-builder';
|
||||
export type { RenderConfig } from './artifact-builder';
|
||||
42
client/src/utils/artifacts/renderers/html-renderer.ts
Normal file
42
client/src/utils/artifacts/renderers/html-renderer.ts
Normal 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
|
||||
});
|
||||
}
|
||||
63
client/src/utils/artifacts/renderers/markdown-renderer.ts
Normal file
63
client/src/utils/artifacts/renderers/markdown-renderer.ts
Normal 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 });
|
||||
}
|
||||
175
client/src/utils/artifacts/renderers/mermaid-renderer.ts
Normal file
175
client/src/utils/artifacts/renderers/mermaid-renderer.ts
Normal 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>`;
|
||||
}
|
||||
40
client/src/utils/artifacts/renderers/react-renderer.ts
Normal file
40
client/src/utils/artifacts/renderers/react-renderer.ts
Normal 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 });
|
||||
}
|
||||
632
client/src/utils/artifacts/renderers/react-runner.ts
Normal file
632
client/src/utils/artifacts/renderers/react-runner.ts
Normal 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>`;
|
||||
}
|
||||
37
client/src/utils/artifacts/renderers/svg-renderer.ts
Normal file
37
client/src/utils/artifacts/renderers/svg-renderer.ts
Normal 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 });
|
||||
}
|
||||
62
client/src/utils/artifacts/templates.ts
Normal file
62
client/src/utils/artifacts/templates.ts
Normal 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>`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue