import CodeBlock from '@theme/CodeBlock';
import * as React from 'react';
import type { CompileOptions, LiveFile, SucraseTransform } from './runtime';
import { buildSrcDoc, CompileError } from './runtime';
import styles from './styles.module.css';
export interface LiveExampleProps extends Omit<CompileOptions, 'id'> {
files: LiveFile[];
parseError?: string;
dark: boolean;
}
interface LiveError {
kind: 'compile' | 'runtime' | 'network';
message: string;
stack?: string;
}
const ERROR_TITLES: Record<LiveError['kind'], string> = {
compile: 'Compilation error',
runtime: 'Runtime error',
network: 'Failed to load dependencies',
};
let sucrasePromise: Promise<SucraseTransform> | undefined;
const loadSucrase = () =>
(sucrasePromise ??= import('sucrase').then(
m => m.transform as unknown as SucraseTransform,
error => {
sucrasePromise = undefined;
throw error;
}
));
const ErrorOverlay = ({ error }: { error: LiveError }) => (
<div className={styles.overlay}>
<div className={styles.overlayTitle}>{ERROR_TITLES[error.kind]}</div>
{error.message}
{error.stack ? `\n\n${error.stack}` : ''}
</div>
);
interface PreviewProps {
srcDoc: string;
id: string;
dark: boolean;
}
* The sandboxed iframe and everything derived from it.
*
* Rendered with `key={srcDoc}` so that a change to the compiled document remounts it, resetting
* `ready`/`height`/`error` without any effect having to reset them by hand.
*/
const Preview = ({ srcDoc, id, dark }: PreviewProps) => {
const [ready, setReady] = React.useState(false);
const [height, setHeight] = React.useState(320);
const [error, setError] = React.useState<LiveError>();
const iframeRef = React.useRef<HTMLIFrameElement>(null);
const postTheme = React.useCallback((isDark: boolean) => {
iframeRef.current?.contentWindow?.postMessage(
{ source: 'rqb-live-host', type: 'theme', dark: isDark },
'*'
);
}, []);
React.useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow) return;
const data = event.data as
| { source?: string; id?: string; type?: string; height?: number }
| undefined;
if (data?.source !== 'rqb-live' || data.id !== id) return;
switch (data.type) {
case 'mounted': {
postTheme(dark);
break;
}
case 'ready': {
setReady(true);
break;
}
case 'height': {
if (typeof data.height === 'number' && data.height > 0) {
setHeight(data.height);
}
break;
}
case 'error': {
setError(data as unknown as LiveError);
setReady(true);
break;
}
}
};
globalThis.addEventListener('message', onMessage);
return () => globalThis.removeEventListener('message', onMessage);
}, [id, dark, postTheme]);
React.useEffect(() => {
postTheme(dark);
}, [dark, postTheme]);
return (
<>
{!ready && <div className={styles.status}>Loading example…</div>}
<iframe
ref={iframeRef}
title="Live example"
className={styles.preview}
style={{ height, visibility: ready ? undefined : 'hidden' }}
sandbox="allow-scripts allow-popups allow-forms allow-modals"
srcDoc={srcDoc}
/>
{error && <ErrorOverlay error={error} />}
</>
);
};
export const LiveExample = ({
files,
dependencies,
extraCSSImports,
parseError,
dark,
}: LiveExampleProps): React.JSX.Element => {
const id = React.useId();
const [transform, setTransform] = React.useState<SucraseTransform>();
const [loadError, setLoadError] = React.useState<LiveError>();
React.useEffect(() => {
let cancelled = false;
loadSucrase().then(
loaded => {
if (!cancelled) setTransform(() => loaded);
return undefined;
},
error => {
if (!cancelled) {
setLoadError({ kind: 'network', message: `Could not load the compiler: ${error}` });
}
}
);
return () => {
cancelled = true;
};
}, []);
const compiled = React.useMemo((): { srcDoc?: string; error?: LiveError } => {
if (!transform || parseError) return {};
try {
const fileMap = Object.fromEntries(files.map(f => [f.path, f]));
return { srcDoc: buildSrcDoc(fileMap, transform, { dependencies, extraCSSImports, id }) };
} catch (error) {
return {
error: {
kind: 'compile',
message: error instanceof CompileError ? error.message : `${(error as Error)?.message}`,
},
};
}
}, [files, dependencies, extraCSSImports, transform, parseError, id]);
const visibleFiles = files.filter(f => !f.hidden);
const [selected, setSelected] = React.useState<string | undefined>(undefined);
const selectedFile =
visibleFiles.find(f => f.path === selected) ??
visibleFiles.find(f => f.active) ??
visibleFiles[0];
const error: LiveError | undefined = parseError
? { kind: 'compile', message: parseError }
: (loadError ?? compiled.error);
const tabId = (path: string) => `${id}-tab-${path}`;
const panelId = `${id}-panel`;
const onTabKeyDown = (event: React.KeyboardEvent) => {
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
if (delta === 0) return;
event.preventDefault();
const index = visibleFiles.findIndex(f => f.path === selectedFile?.path);
const next = visibleFiles[(index + delta + visibleFiles.length) % visibleFiles.length];
setSelected(next.path);
(
event.currentTarget.parentElement?.querySelector(`#${CSS.escape(tabId(next.path))}`) as
| HTMLElement
| undefined
)?.focus();
};
return (
<div className={styles.liveExample}>
<div className={styles.codeColumn}>
{visibleFiles.length > 1 && (
<div className={styles.tabs} role="tablist">
{visibleFiles.map(file => (
<button
key={file.path}
id={tabId(file.path)}
type="button"
role="tab"
aria-selected={file.path === selectedFile?.path}
aria-controls={panelId}
tabIndex={file.path === selectedFile?.path ? 0 : -1}
className={`${styles.tab} ${
file.path === selectedFile?.path ? styles.tabActive : ''
}`}
onKeyDown={onTabKeyDown}
onClick={() => setSelected(file.path)}>
{file.path.replace(/^\//, '')}
</button>
))}
</div>
)}
{selectedFile && (
<div
className={styles.code}
id={panelId}
role={visibleFiles.length > 1 ? 'tabpanel' : undefined}
aria-labelledby={visibleFiles.length > 1 ? tabId(selectedFile.path) : undefined}>
<CodeBlock language={selectedFile.lang}>{selectedFile.code}</CodeBlock>
</div>
)}
</div>
<div className={styles.previewColumn}>
{compiled.srcDoc ? (
<Preview key={compiled.srcDoc} srcDoc={compiled.srcDoc} id={id} dark={dark} />
) : (
!error && <div className={styles.status}>Preparing example…</div>
)}
{error && <ErrorOverlay error={error} />}
</div>
</div>
);
};