The weeb for the next gen discord boat - Wamellow
wamellow.com
bot
discord
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}