One-click backups for AT Protocol
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}