The weeb for the next gen discord boat - Wamellow wamellow.com
bot discord
at master 8.7 kB view raw
1/* eslint-disable @typescript-eslint/no-unused-vars */ 2 3import { getUser } from "@/lib/discord/user"; 4import { cn } from "@/utils/cn"; 5import { filterDuplicates } from "@/utils/filter-duplicates"; 6import { getBaseUrl } from "@/utils/urls"; 7import Link from "next/link"; 8import type { ReactNode } from "react"; 9import ReactMarkdown from "react-markdown"; 10import rehypeRaw from "rehype-raw"; 11 12import Channel from "./channel"; 13import Emoji from "./emoji"; 14import Timestamp from "./timestamp"; 15import User from "./user"; 16import Notice, { NoticeType } from "../notice"; 17import { Separator } from "../ui/separator"; 18import { Anchor, Code } from "../ui/typography"; 19 20const ALLOWED_IFRAMES = [ 21 "https://www.youtube.com/embed/", 22 "https://e.widgetbot.io/channels/", 23 getBaseUrl() 24] as const; 25 26const EMOJI_REGEX = /([\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{200D}]|[\u{23CF}]|[\u{23E9}-\u{23FA}]|[\u{24C2}]|[\u{25AA}-\u{25AB}]|[\u{25FB}-\u{25FE}]|[\u{00A9}]|[\u{00AE}]|[\u{203C}]|[\u{2049}]|[\u{2122}]|[\u{2139}]|[\u{2194}-\u{2199}]|[\u{21A9}-\u{21AA}]|[\u{231A}-\u{231B}]|[\u{2328}]|[\u{2388}]|[\u{23E9}-\u{23EC}]|[\u{23F0}]|[\u{23F3}]|[\u{25FD}-\u{25FE}]|[\u{2614}-\u{2615}]|[\u{2648}-\u{2653}]|[\u{267F}]|[\u{2692}-\u{269C}]|[\u{26F5}]|[\u{26FA}]|[\u{26FD}]|[\u{2705}]|[\u{270A}-\u{270B}]|[\u{2728}]|[\u{274C}]|[\u{274E}]|[\u{2753}-\u{2755}]|[\u{2757}]|[\u{2795}-\u{2797}]|[\u{27B0}]|[\u{27BF}]|[\u{2B1B}-\u{2B1C}]|[\u{2B50}]|[\u{2B55}]|[\u{2934}-\u{2935}]|[\u{3030}]|[\u{303D}]|[\u{3297}]|[\u{3299}])/gu; 27 28export default async function BeautifyMarkdown({ 29 markdown 30}: { 31 markdown: string; 32}) { 33 const { renderToString } = await import("react-dom/server"); 34 35 async function parseDiscordMarkdown(content: string) { 36 const users = await Promise.all( 37 filterDuplicates(content.match(/<@!?\d{15,21}>/g) || []) 38 .map((match) => match.replace(/[!<>@]/g, "")) 39 .map((userId) => getUser(userId)) 40 ); 41 42 return content 43 .replace(/__(.*?)__/g, "<u>$1</u>") 44 .replace(/<a?:\w{2,32}:\d{15,21}>/g, (match) => { 45 const emojiId = match.match(/\d{15,21}/)?.[0] as string; 46 47 return renderToString(<Emoji emojiId={emojiId} />); 48 }) 49 .replace(/<(@!?)\d{15,21}>/g, (match) => { 50 const userId = match.replace(/[!<>@]/g, ""); 51 const username = users.find((user) => user?.id === userId)?.username || "user"; 52 53 return renderToString(<User username={username} />); 54 }) 55 .replace(/<(@&)\d{15,21}>/g, () => { 56 return renderToString(<User username="some-role" />); 57 }) 58 .replace(/<(#!?)\d{15,21}>/g, () => { 59 return renderToString(<Channel name="some-channel" />); 60 }) 61 .replace(/<t:\d{1,10}:[Rf]?>/g, (match) => { 62 const timestamp = match.match(/\d{1,10}/)?.[0] as string; 63 const format = match.match(/:\w*?>/)?.[0] || "f"; 64 65 return renderToString( 66 <Timestamp 67 unix={Number.parseInt(timestamp, 10)} 68 format={format.slice(1, -1)} 69 /> 70 ); 71 }); 72 } 73 74 return ( 75 <ReactMarkdown 76 rehypePlugins={[rehypeRaw]} 77 components={{ 78 h1: (props) => (<> 79 <Link 80 href={`#${createHId(props.children)}`} 81 className="flex mt-10 mb-3 cursor-pointer dark:text-neutral-100 text-neutral-900 hover:underline w-fit" 82 > 83 <h2 id={createHId(props.children)} className="text-3xl font-semibold" {...props} /> 84 </Link> 85 <Separator className="mb-3" /> 86 </>), 87 88 h2: (props) => (<> 89 <Link 90 href={`#${createHId(props.children)}`} 91 className="flex mt-6 mb-2 cursor-pointer dark:text-neutral-100 text-neutral-900 hover:underline w-fit" 92 > 93 <h1 id={createHId(props.children)} className="text-2xl font-semibold" {...props} /> 94 </Link> 95 <Separator className="mb-3" /> 96 </>), 97 98 h3: (props) => ( 99 <Link 100 href={`#${createHId(props.children)}`} 101 className="flex mt-6 mb-2 cursor-pointer dark:text-neutral-100 text-neutral-900 hover:underline w-fit" 102 > 103 <h3 id={createHId(props.children)} className="text-lg font-semibold" {...props} /> 104 </Link> 105 ), 106 107 strong: (props) => <span className="font-semibold dark:text-neutral-200 text-neutral-800" {...props} />, 108 i: (props) => <span className="italic" {...props} />, 109 del: (props) => <span className="line-through" {...props} />, 110 ins: (props) => <span className="underline" {...props} />, 111 112 code: ({ ref, color, ...props }) => <Code {...props}>{props.children}</Code>, 113 img: ({ alt = "image", ...props }) => { 114 const isFullWidth = typeof props.src === "string" && props.src?.includes("fullwidth=true"); 115 116 return ( 117 <span 118 className={cn( 119 "w-fit flex-col items-center inline", 120 alt === "emoji" ? "inline" : "flex", 121 isFullWidth ? "max-w-3xl" : "max-w-lg" 122 )} 123 > 124 {/* eslint-disable-next-line @next/next/no-img-element */} 125 <img alt={alt} className={cn("rounded-lg", alt === "emoji" && "inline")} loading="lazy" {...props} /> 126 {alt && alt !== "emoji" && <span aria-hidden="true" className="text-neutral-500 font-medium text-sm text-center">{alt}</span>} 127 </span> 128 ); 129 }, 130 a: ({ href, children }) => <Anchor href={href || "#"} target="_blank">{children}</Anchor>, 131 132 table: (props) => <table className="mt-4 table-auto w-full divide-y divide-wamellow overflow-scroll" {...props} />, 133 th: (props) => <th className=" px-2 pb-2 font-medium text-neutral-800 dark:text-neutral-200 text-left" {...props} />, 134 tbody: (props) => <tbody className="[&>*:nth-child(odd)]:bg-neutral-800/15" {...props} />, 135 tr: (props) => <tr className="divide-x divide-wamellow" {...props} />, 136 td: (props) => <td className="px-2 py-1 divide-x-8 divide-wamellow break-all" {...props} />, 137 138 iframe: ({ className, ...props }) => { 139 if (ALLOWED_IFRAMES.some((url) => props.src?.startsWith(url))) { 140 return ( 141 <iframe 142 allow="clipboard-write; fullscreen" 143 className={cn( 144 "w-full rounded-lg mt-4", 145 className 146 )} 147 {...props} 148 /> 149 ); 150 } 151 152 return ( 153 <div className="mt-4"> 154 <Notice 155 type={NoticeType.Error} 156 message={`Iframe from "${props.src?.split("/")[2]}" is not allowed`} 157 /> 158 </div> 159 ); 160 }, 161 162 ol: (props) => <ol className="list-decimal list-inside space-y-1 marker:text-neutral-300/40 my-1" {...props} />, 163 ul: (props) => <ul className="list-disc list-inside space-y-1 marker:text-neutral-300/40 my-1" {...props} />, 164 p: (props) => <span {...props} />, 165 166 mark: ({ children }) => <Notice type={NoticeType.Info} message={children?.toString() || ""} /> 167 168 }} 169 > 170 {await parseDiscordMarkdown(markdown)} 171 </ReactMarkdown> 172 ); 173} 174 175function createHId(text: ReactNode) { 176 return text 177 ?.toString() 178 .toLowerCase() 179 .replace("[object object],[object object],", "") 180 .replace(EMOJI_REGEX, "") 181 .trim() 182 .replace(/ +/g, "-"); 183}