import { Light as SyntaxHighlighter } from '@d-fischer/react-syntax-highlighter'; import jsHighlight from '@d-fischer/react-syntax-highlighter/dist/esm/languages/hljs/javascript'; import json from '@d-fischer/react-syntax-highlighter/dist/esm/languages/hljs/json'; import tsHighlight from '@d-fischer/react-syntax-highlighter/dist/esm/languages/hljs/typescript'; import darcula from '@d-fischer/react-syntax-highlighter/dist/esm/styles/hljs/darcula'; import { makeStyles } from '@mui/styles'; import { twoslasher } from '@typescript/twoslash'; import * as lzString from 'lz-string'; import React, { useCallback, useMemo, useState } from 'react'; import * as ts from 'typescript'; import { fsMap } from '../../progressiveEnhancement/fsMap.js'; import { getRandomString } from '../tools/StringTools.js'; SyntaxHighlighter.registerLanguage('javascript', jsHighlight); SyntaxHighlighter.registerLanguage('js', jsHighlight); SyntaxHighlighter.registerLanguage('json', json); SyntaxHighlighter.registerLanguage('typescript', tsHighlight); SyntaxHighlighter.registerLanguage('ts', tsHighlight); interface CodeBlockProps { lang?: string; langMeta: string[]; text: string; } export function friendlyCjsTransform(): ts.TransformerFactory { return (ctx: ts.TransformationContext) => { const { factory } = ctx; const visitor = (node: ts.Node): ts.Node => { if ( ts.isImportDeclaration(node) && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) ) { return factory.createVariableStatement( undefined, factory.createVariableDeclarationList( [ factory.createVariableDeclaration( factory.createObjectBindingPattern( node.importClause.namedBindings.elements.map(elem => factory.createBindingElement(undefined, elem.propertyName, elem.name) ) ), undefined, undefined, factory.createCallExpression(factory.createIdentifier('require'), undefined, [ node.moduleSpecifier ]) ) ], ts.NodeFlags.Const ) ); } return ts.visitEachChild(node, visitor, ctx); }; return node => ts.visitNode(node, visitor, ts.isSourceFile); }; } const useStyles = makeStyles(theme => ({ wrapper: { position: 'relative' }, modeSwitcher: { position: 'absolute', right: theme.spacing.unit, top: theme.spacing.unit, display: 'flex', border: `1px solid ${theme.colors.border}`, opacity: 0, pointerEvents: 'none', transition: 'opacity .5s ease-in-out', '$wrapper:hover &': { pointerEvents: 'auto', opacity: 1 } }, modeInput: { display: 'none' }, mode: { padding: theme.spacing.unit, borderLeft: `1px solid ${theme.colors.border}`, transition: 'background-color .3s ease-in-out', cursor: 'pointer', '&:first-of-type': { borderLeft: '0 none' }, 'input:checked + &': { background: theme.colors.border } }, code: { tabSize: 2 }, diffAddedLine: { display: 'block', backgroundColor: 'rgba(9, 180, 50, 0.25)' }, diffRemovedLine: { display: 'block', backgroundColor: 'rgba(255, 106, 105, 0.25)' } })); // clean up a tiny bit of twoslash specific stuff for the static output function getCleanText(text: string) { return text.replace(/[\w\W]*?(?:^|\n)\/\/ ---cut---\n/, '').replace(/\/\/ @.*\n/g, ''); } function range(start: number, end: number) { return Array.from({ length: end - start + 1 }, (_, i) => i + start); } function parseRanges(str: string | undefined) { return str?.split(',').flatMap(val => { const [start, end] = val.split('-'); if (end) { return range(Number(start), Number(end)); } return [Number(start)]; }); } export const CodeBlock: React.FC = __DOCTS_COMPONENT_MODE === 'static' ? props => { const { lang, langMeta, text } = props; const classes = useStyles(); const [langMetaName, ...langMetaAdditional] = langMeta; if (langMetaName === 'twoslash') { const cleanText = getCleanText(text); return (
{cleanText}
); } if (langMetaName === 'diff') { const expandedAdded = parseRanges(langMetaAdditional.find(tk => tk.startsWith('+'))?.slice(1)); const expandedRemoved = parseRanges(langMetaAdditional.find(tk => tk.startsWith('-'))?.slice(1)); return ( { let className: string | undefined = undefined; if (expandedAdded?.includes(lineNumber)) { className = classes.diffAddedLine; } else if (expandedRemoved?.includes(lineNumber)) { className = classes.diffRemovedLine; } return { className }; }} > {text} ); } return ( {text} ); } : ({ lang, langMeta, text }) => { const [langMetaName, ...langMetaAdditional] = langMeta; const isTwoslash = langMetaName === 'twoslash'; const classes = useStyles(); const [idSuffix] = useState(() => getRandomString(8)); const [mode, setMode] = useState('ts'); const transpile = mode !== 'ts'; const showCjs = mode === 'cjs'; const changeMode = useCallback>>(e => { setMode(e.currentTarget.value); }, []); const twoslashed = useMemo(() => { if (isTwoslash) { try { const result = twoslasher(text, 'ts', { defaultOptions: { showEmit: transpile }, tsModule: ts, lzstringModule: lzString, fsMap, customTransformers: showCjs ? { after: [friendlyCjsTransform()] } : undefined }); result.code = result.code.replace(/\n$/, ''); return result; } catch (e) { // eslint-disable-next-line no-console console.error('Error rendering twoslash', e); return { code: getCleanText(text), error: e as Error }; } } else { return undefined; } }, [text, transpile, showCjs]); if (isTwoslash) { return (
{twoslashed!.code}
); } if (langMetaName === 'diff') { const expandedAdded = parseRanges(langMetaAdditional.find(tk => tk.startsWith('+'))?.slice(1)); const expandedRemoved = parseRanges(langMetaAdditional.find(tk => tk.startsWith('-'))?.slice(1)); return ( { let className: string | undefined = undefined; if (expandedAdded?.includes(lineNumber)) { className = classes.diffAddedLine; } else if (expandedRemoved?.includes(lineNumber)) { className = classes.diffRemovedLine; } return { className }; }} codeTagProps={{ className: classes.code }} > {text} ); } return ( {text} ); };