One-click backups for AT Protocol
at main 223 lines 6.2 kB view raw
1import React, { Suspense } from "react"; 2import Markdown from "react-markdown"; 3import remarkGfm from "remark-gfm"; 4import type { ThemedToken } from "shiki"; 5 6import { cn } from "@/lib/utils"; 7import { CopyButton } from "@/components/ui/copy-button"; 8 9interface MarkdownRendererProps { 10 children: string; 11} 12 13export function MarkdownRenderer({ children }: MarkdownRendererProps) { 14 return ( 15 <div className="space-y-3"> 16 <Markdown remarkPlugins={[remarkGfm]} components={COMPONENTS}> 17 {children} 18 </Markdown> 19 </div> 20 ); 21} 22 23interface HighlightedPre extends React.HTMLAttributes<HTMLPreElement> { 24 children: string; 25 language: string; 26} 27 28// Synchronous wrapper that uses Suspense 29const HighlightedPre = ({ children, language, ...props }: HighlightedPre) => { 30 return ( 31 <Suspense fallback={<pre {...props}>{children}</pre>}> 32 <AsyncHighlightedPre language={language} {...props}> 33 {children} 34 </AsyncHighlightedPre> 35 </Suspense> 36 ); 37}; 38 39// Async logic moved here, loaded with lazy or dynamic inside the component 40const AsyncHighlightedPre = (props: HighlightedPre) => { 41 const [tokens, setTokens] = React.useState<ThemedToken[][] | null>([]); 42 const [loaded, setLoaded] = React.useState(false); 43 44 React.useEffect(() => { 45 (async () => { 46 const { codeToTokens, bundledLanguages } = await import("shiki"); 47 48 if (!(props.language in bundledLanguages)) { 49 setTokens(null); 50 setLoaded(true); 51 return; 52 } 53 54 const { tokens } = await codeToTokens(props.children, { 55 lang: props.language as keyof typeof bundledLanguages, 56 defaultColor: false, 57 themes: { 58 light: "github-light", 59 dark: "github-dark", 60 }, 61 }); 62 63 setTokens(tokens); 64 setLoaded(true); 65 })(); 66 }, [props.children, props.language]); 67 68 if (!loaded) { 69 return <pre {...props}>{props.children}</pre>; 70 } 71 72 if (!tokens) { 73 return <pre {...props}>{props.children}</pre>; 74 } 75 76 return ( 77 <pre {...props}> 78 <code> 79 {tokens.map((line, lineIndex) => ( 80 <span key={lineIndex}> 81 {line.map((token, tokenIndex) => { 82 const style = 83 typeof token.htmlStyle === "string" 84 ? undefined 85 : token.htmlStyle; 86 87 return ( 88 <span 89 key={tokenIndex} 90 className="text-shiki-light bg-shiki-light-bg dark:text-shiki-dark dark:bg-shiki-dark-bg" 91 style={style} 92 > 93 {token.content} 94 </span> 95 ); 96 })} 97 {"\n"} 98 </span> 99 ))} 100 </code> 101 </pre> 102 ); 103}; 104 105HighlightedPre.displayName = "HighlightedCode"; 106 107interface CodeBlockProps extends React.HTMLAttributes<HTMLPreElement> { 108 children: React.ReactNode; 109 className?: string; 110 language: string; 111} 112 113const CodeBlock = ({ 114 children, 115 className, 116 language, 117 ...restProps 118}: CodeBlockProps) => { 119 const code = 120 typeof children === "string" 121 ? children 122 : childrenTakeAllStringContents(children); 123 124 const preClass = cn( 125 "overflow-x-scroll rounded-md border bg-background/50 p-4 font-mono text-sm [scrollbar-width:none]", 126 className 127 ); 128 129 return ( 130 <div className="group/code relative mb-4"> 131 <Suspense 132 fallback={ 133 <pre className={preClass} {...restProps}> 134 {children} 135 </pre> 136 } 137 > 138 <HighlightedPre language={language} className={preClass}> 139 {code} 140 </HighlightedPre> 141 </Suspense> 142 143 <div className="invisible absolute right-2 top-2 flex space-x-1 rounded-lg p-1 opacity-0 transition-all duration-200 group-hover/code:visible group-hover/code:opacity-100"> 144 <CopyButton content={code} copyMessage="Copied code to clipboard" /> 145 </div> 146 </div> 147 ); 148}; 149 150function childrenTakeAllStringContents(element: any): string { 151 if (typeof element === "string") { 152 return element; 153 } 154 155 if (element?.props?.children) { 156 let children = element.props.children; 157 158 if (Array.isArray(children)) { 159 return children 160 .map((child) => childrenTakeAllStringContents(child)) 161 .join(""); 162 } else { 163 return childrenTakeAllStringContents(children); 164 } 165 } 166 167 return ""; 168} 169 170const COMPONENTS = { 171 h1: withClass("h1", "text-2xl font-semibold"), 172 h2: withClass("h2", "font-semibold text-xl"), 173 h3: withClass("h3", "font-semibold text-lg"), 174 h4: withClass("h4", "font-semibold text-base"), 175 h5: withClass("h5", "font-medium"), 176 strong: withClass("strong", "font-semibold"), 177 a: withClass("a", "text-primary underline underline-offset-2"), 178 blockquote: withClass("blockquote", "border-l-2 border-primary pl-4"), 179 code: ({ children, className, node, ...rest }: any) => { 180 const match = /language-(\w+)/.exec(className || ""); 181 return match ? ( 182 <CodeBlock className={className} language={match[1]} {...rest}> 183 {children} 184 </CodeBlock> 185 ) : ( 186 <code 187 className={cn( 188 "font-mono [:not(pre)>&]:rounded-md [:not(pre)>&]:bg-background/50 [:not(pre)>&]:px-1 [:not(pre)>&]:py-0.5" 189 )} 190 {...rest} 191 > 192 {children} 193 </code> 194 ); 195 }, 196 pre: ({ children }: any) => children, 197 ol: withClass("ol", "list-decimal space-y-2 pl-6"), 198 ul: withClass("ul", "list-disc space-y-2 pl-6"), 199 li: withClass("li", "my-1.5"), 200 table: withClass( 201 "table", 202 "w-full border-collapse overflow-y-auto rounded-md border border-foreground/20" 203 ), 204 th: withClass( 205 "th", 206 "border border-foreground/20 px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right" 207 ), 208 td: withClass( 209 "td", 210 "border border-foreground/20 px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right" 211 ), 212 tr: withClass("tr", "m-0 border-t p-0 even:bg-muted"), 213 p: withClass("p", "whitespace-pre-wrap"), 214 hr: withClass("hr", "border-foreground/20"), 215}; 216 217function withClass(Tag: keyof JSX.IntrinsicElements, classes: string) { 218 const Component = ({ node, ...props }: any) => ( 219 <Tag className={classes} {...props} /> 220 ); 221 Component.displayName = Tag; 222 return Component; 223}