CVE-2023-0106: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Cross-site Scripting (XSS) - Stored in GitHub repository usememos/memos prior to 0.10.0.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 5.4
Published: 2023-01-07T04:15Z
Repository: https://github.com/usememos/memos
Language: Other
Files changed: web/src/components/CreateTagDialog.tsx, web/src/components/Memo.tsx, web/src/components/MemoContent.tsx, web/src/components/MemoList.tsx, web/src/labs/marked/index.tsx, web/src/labs/marked/matcher.ts, web/src/labs/marked/parser/Blockquote.tsx, web/src/labs/marked/parser/Bold.tsx, web/src/labs/marked/parser/BoldEmphasis.tsx, web/src/labs/marked/parser/Br.tsx, web/src/labs/marked/parser/CodeBlock.tsx, web/src/labs/marked/parser/DoneList.tsx, web/src/labs/marked/parser/Emphasis.tsx, web/src/labs/marked/parser/Heading.tsx, web/src/labs/marked/parser/HorizontalRules.tsx, web/src/labs/marked/parser/Image.tsx, web/src/labs/marked/parser/InlineCode.tsx, web/src/labs/marked/parser/Link.tsx, web/src/labs/marked/parser/OrderedList.tsx, web/src/labs/marked/parser/Paragraph.tsx, web/src/labs/marked/parser/PlainLink.tsx, web/src/labs/marked/parser/PlainText.tsx, web/src/labs/marked/parser/Strikethrough.tsx, web/src/labs/marked/parser/Tag.tsx, web/src/labs/marked/parser/TodoList.tsx, web/src/labs/marked/parser/UnorderedList.tsx
Commit date: 2023-01-07 00:13:49 +0800
Commit message: refactor: return jsx element instead of string in marked (#910)

* refactor: return jsx element instead of string in marked

* chore: update
Diff: diff --git a/web/src/components/CreateTagDialog.tsx b/web/src/components/CreateTagDialog.tsx
index ea2ab68..3dc57e6 100644
--- a/web/src/components/CreateTagDialog.tsx
+++ b/web/src/components/CreateTagDialog.tsx
@@ -1,18 +1,19 @@
 import { TextField } from "@mui/joy";
 import React, { useEffect, useState } from "react";
 import { useTagStore } from "../store/module";
 import { getTagSuggestionList } from "../helpers/api";
+import { matcher } from "../labs/marked/matcher";
 import Tag from "../labs/marked/parser/Tag";
 import Icon from "./Icon";
 import toastHelper from "./Toast";
 import { generateDialog } from "./Dialog";
 
 type Props = DialogProps;
 
 const validateTagName = (tagName: string): boolean => {
-  const matchResult = Tag.matcher(`#${tagName}`);
+  const matchResult = matcher(`#${tagName}`, Tag.regexp);
   if (!matchResult || matchResult[1] !== tagName) {
     return false;
   }
   return true;
 };
diff --git a/web/src/components/Memo.tsx b/web/src/components/Memo.tsx
index bd2468d..499785d 100644
--- a/web/src/components/Memo.tsx
+++ b/web/src/components/Memo.tsx
@@ -13,11 +13,10 @@ import showPreviewImageDialog from "./PreviewImageDialog";
 import showChangeMemoCreatedTsDialog from "./ChangeMemoCreatedTsDialog";
 import "../less/memo.less";
 
 interface Props {
   memo: Memo;
-  highlightWord?: string;
 }
 
 export const getFormatedMemoTimeStr = (time: number, locale = "en"): string => {
   if (Date.now() - time < 1000 * 60 * 60 * 24) {
     return dayjs(time).locale(locale).fromNow();
@@ -25,11 +24,11 @@ export const getFormatedMemoTimeStr = (time: number, locale = "en"): string => {
     return dayjs(time).locale(locale).format("YYYY/MM/DD HH:mm:ss");
   }
 };
 
 const Memo: React.FC<Props> = (props: Props) => {
-  const { memo, highlightWord } = props;
+  const { memo } = props;
   const { t, i18n } = useTranslation();
   const navigate = useNavigate();
   const editorStore = useEditorStore();
   const locationStore = useLocationStore();
   const userStore = useUserStore();
@@ -141,13 +140,10 @@ const Memo: React.FC<Props> = (props: Props) => {
     } else if (targetEl.tagName === "IMG") {
       const imgUrl = targetEl.getAttribute("src");
       if (imgUrl) {
         showPreviewImageDialog([imgUrl], 0);
       }
-    } else if (targetEl.tagName === "BUTTON" && targetEl.className === "codeblock-copy-btn") {
-      copy(targetEl.parentElement?.children[1].textContent ?? "");
-      toastHelper.success(t("message.succeed-copy-code"));
     }
   };
 
   const handleMemoContentDoubleClick = (e: React.MouseEvent) => {
     const targetEl = e.target as HTMLElement;
@@ -226,11 +222,10 @@ const Memo: React.FC<Props> = (props: Props) => {
           </div>
         )}
       </div>
       <MemoContent
         content={memo.content}
-        highlightWord={highlightWord}
         onMemoContentClick={handleMemoContentClick}
         onMemoContentDoubleClick={handleMemoContentDoubleClick}
       />
       <MemoResources resourceList={memo.resourceList} />
     </div>
diff --git a/web/src/components/MemoContent.tsx b/web/src/components/MemoContent.tsx
index a486eb0..20bd038 100644
--- a/web/src/components/MemoContent.tsx
+++ b/web/src/components/MemoContent.tsx
@@ -1,20 +1,18 @@
 import { useEffect, useMemo, useRef, useState } from "react";
 import { useTranslation } from "react-i18next";
 import { useUserStore } from "../store/module";
 import { marked } from "../labs/marked";
-import { highlightWithWord } from "../labs/highlighter";
 import Icon from "./Icon";
 import "../less/memo-content.less";
 
 export interface DisplayConfig {
   enableExpand: boolean;
 }
 
 interface Props {
   content: string;
-  highlightWord?: string;
   className?: string;
   displayConfig?: Partial<DisplayConfig>;
   onMemoContentClick?: (e: React.MouseEvent) => void;
   onMemoContentDoubleClick?: (e: React.MouseEvent) => void;
 }
@@ -28,18 +26,18 @@ interface State {
 const defaultDisplayConfig: DisplayConfig = {
   enableExpand: true,
 };
 
 const MemoContent: React.FC<Props> = (props: Props) => {
-  const { className, content, highlightWord, onMemoContentClick, onMemoContentDoubleClick } = props;
+  const { className, content, onMemoContentClick, onMemoContentDoubleClick } = props;
+  const { t } = useTranslation();
+  const userStore = useUserStore();
+  const user = userStore.state.user;
   const foldedContent = useMemo(() => {
     const firstHorizontalRuleIndex = content.search(/^---$|^\*\*\*$|^___$/m);
     return firstHorizontalRuleIndex !== -1 ? content.slice(0, firstHorizontalRuleIndex) : content;
   }, [content]);
-  const { t } = useTranslation();
-  const userStore = useUserStore();
-  const user = userStore.state.user;
 
   const [state, setState] = useState<State>({
     expandButtonStatus: -1,
   });
   const memoContentContainerRef = useRef<HTMLDivElement>(null);
@@ -95,14 +93,13 @@ const MemoContent: React.FC<Props> = (props: Props) => {
       <div
         ref={memoContentContainerRef}
         className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
         onClick={handleMemoContentClick}
         onDoubleClick={handleMemoContentDoubleClick}
-        dangerouslySetInnerHTML={{
-          __html: highlightWithWord(marked(state.expandButtonStatus === 0 ? foldedContent : content), highlightWord),
-        }}
-      ></div>
+      >
+        {marked(state.expandButtonStatus === 0 ? foldedContent : content)}
+      </div>
       {state.expandButtonStatus !== -1 && (
         <div className="expand-btn-container">
           <span className={`btn ${state.expandButtonStatus === 0 ? "expand-btn" : "fold-btn"}`} onClick={handleExpandBtnClick}>
             {state.expandButtonStatus === 0 ? t("common.expand") : t("common.fold")}
             <Icon.ChevronRight className="icon-img" />
diff --git a/web/src/components/MemoList.tsx b/web/src/components/MemoList.tsx
index 55c7a00..cf66fbd 100644
--- a/web/src/components/MemoList.tsx
+++ b/web/src/components/MemoList.tsx
@@ -17,11 +17,10 @@ const MemoList = () => {
   const locationStore = useLocationStore();
   const query = locationStore.state.query;
   const memoDisplayTsOption = userStore.state.user?.setting.memoDisplayTsOption;
   const { memos, isFetching } = memoStore.state;
   const [isComplete, setIsComplete] = useState<boolean>(false);
-  const [highlightWord, setHighlightWord] = useState<string | undefined>("");
 
   const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId, visibility } = query ?? {};
   const shortcut = shortcutId ? shortcutStore.getShortcutById(shortcutId) : null;
   const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || shortcut || visibility);
 
@@ -105,11 +104,10 @@ const MemoList = () => {
   useEffect(() => {
     const pageWrapper = document.body.querySelector(".page-wrapper");
     if (pageWrapper) {
       pageWrapper.scrollTo(0, 0);
     }
-    setHighlightWord(query?.text);
   }, [query]);
 
   useEffect(() => {
     if (isFetching || isComplete) {
       return;
@@ -134,11 +132,11 @@ const MemoList = () => {
   };
 
   return (
     <div className="memo-list-container">
       {sortedMemos.map((memo) => (
-        <Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} highlightWord={highlightWord} />
+        <Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} />
       ))}
       {isFetching ? (
         <div className="status-text-container fetching-tip">
           <p className="status-text">{t("memo-list.fetching-data")}</p>
         </div>
diff --git a/web/src/labs/highlighter/index.ts b/web/src/labs/highlighter/index.ts
deleted file mode 100644
index d18e6cb..0000000
--- a/web/src/labs/highlighter/index.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { escape } from "lodash";
-
-const walkthroughNodeWithKeyword = (node: HTMLElement, keyword: string) => {
-  if (node.nodeType === 3) {
-    const span = document.createElement("span");
-    span.innerHTML = node.nodeValue?.replace(new RegExp(keyword, "g"), `<mark>${keyword}</mark>`) ?? "";
-    node.parentNode?.insertBefore(span, node);
-    node.parentNode?.removeChild(node);
-  }
-  for (const child of Array.from(node.childNodes)) {
-    walkthroughNodeWithKeyword(<HTMLElement>child, keyword);
-  }
-  return node.innerHTML;
-};
-
-export const highlightWithWord = (html: string, keyword?: string): string => {
-  if (!keyword) {
-    return html;
-  }
-  keyword = escape(keyword);
-  const wrap = document.createElement("div");
-  wrap.innerHTML = escape(html);
-  return walkthroughNodeWithKeyword(wrap, keyword);
-};
diff --git a/web/src/labs/marked/index.ts b/web/src/labs/marked/index.tsx
similarity index 72%
rename from web/src/labs/marked/index.ts
rename to web/src/labs/marked/index.tsx
index 0d3d083..bbae6c4 100644
--- a/web/src/labs/marked/index.ts
+++ b/web/src/labs/marked/index.tsx
@@ -1,32 +1,47 @@
+import { matcher } from "./matcher";
 import { blockElementParserList, inlineElementParserList } from "./parser";
 
-export const marked = (markdownStr: string, blockParsers = blockElementParserList, inlineParsers = inlineElementParserList): string => {
+export const marked = (
+  markdownStr: string,
+  blockParsers = blockElementParserList,
+  inlineParsers = inlineElementParserList
+): string | JSX.Element => {
   for (const parser of blockParsers) {
-    const matchResult = parser.matcher(markdownStr);
+    const matchResult = matcher(markdownStr, parser.regexp);
     if (!matchResult) {
       continue;
     }
     const matchedStr = matchResult[0];
     const retainContent = markdownStr.slice(matchedStr.length);
 
     if (parser.name === "br") {
-      return parser.renderer(matchedStr) + marked(retainContent, blockParsers, inlineParsers);
+      return (
+        <>
+          {parser.renderer(matchedStr)}
+          {marked(retainContent, blockParsers, inlineParsers)}
+        </>
+      );
     } else {
       if (retainContent === "") {
         return parser.renderer(matchedStr);
       } else if (retainContent.startsWith("\n")) {
-        return parser.renderer(matchedStr) + marked(retainContent.slice(1), blockParsers, inlineParsers);
+        return (
+          <>
+            {parser.renderer(matchedStr)}
+            {marked(retainContent.slice(1), blockParsers, inlineParsers)}
+          </>
+        );
       }
     }
   }
 
   let matchedInlineParser = undefined;
   let matchedIndex = -1;
 
   for (const parser of inlineParsers) {
-    const matchResult = parser.matcher(markdownStr);
+    const matchResult = matcher(markdownStr, parser.regexp);
     if (!matchResult) {
       continue;
     }
 
     if (parser.name === "plain text" && matchedInlineParser !== undefined) {
@@ -39,21 +54,27 @@ export const marked = (markdownStr: string, blockParsers = blockElementParserLis
       matchedIndex = startIndex;
     }
   }
 
   if (matchedInlineParser) {
-    const matchResult = matchedInlineParser.matcher(markdownStr);
+    const matchResult = matcher(markdownStr, matchedInlineParser.regexp);
     if (matchResult) {
       const matchedStr = matchResult[0];
       const matchedLength = matchedStr.length;
       const prefixStr = markdownStr.slice(0, matchedIndex);
       const suffixStr = markdownStr.slice(matchedIndex + matchedLength);
-      return marked(prefixStr, [], inlineParsers) + matchedInlineParser.renderer(matchedStr) + marked(suffixStr, [], inlineParsers);
+      return (
+        <>
+          {marked(prefixStr, [], inlineParsers)}
+          {matchedInlineParser.renderer(matchedStr)}
+          {marked(suffixStr, [], inlineParsers)}
+        </>
+      );
     }
   }
 
-  return markdownStr;
+  return <>{markdownStr}</>;
 };
 
 interface MatchedNode {
   parserName: string;
   matchedContent: string;
@@ -62,11 +83,11 @@ interface MatchedNode {
 export const getMatchedNodes = (markdownStr: string): MatchedNode[] => {
   const matchedNodeList: MatchedNode[] = [];
 
   const walkthough = (markdownStr: string, blockParsers = blockElementParserList, inlineParsers = inlineElementParserList): string => {
     for (const parser of blockParsers) {
-      const matchResult = parser.matcher(markdownStr);
+      const matchResult = matcher(markdownStr, parser.regexp);
       if (!matchResult) {
         continue;
       }
       const matchedStr = matchResult[0];
       const retainContent = markdownStr.slice(matchedStr.length);
@@ -77,20 +98,21 @@ export const getMatchedNodes = (markdownStr: string): MatchedNode[] => {
 
       if (parser.name === "br") {
         return walkthough(retainContent, blockParsers, inlineParsers);
       } else {
         if (retainContent.startsWith("\n")) {
+          walkthough(matchedStr, [], inlineParsers);
           return walkthough(retainContent.slice(1), blockParsers, inlineParsers);
         }
       }
     }
 
     let matchedInlineParser = undefined;
     let matchedIndex = -1;
 
     for (const parser of inlineParsers) {
-      const matchResult = parser.matcher(markdownStr);
+      const matchResult = matcher(markdownStr, parser.regexp);
       if (!matchResult) {
         continue;
       }
 
       if (parser.name === "plain text" && matchedInlineParser !== undefined) {
@@ -103,11 +125,11 @@ export const getMatchedNodes = (markdownStr: string): MatchedNode[] => {
         matchedIndex = startIndex;
       }
     }
 
     if (matchedInlineParser) {
-      const matchResult = matchedInlineParser.matcher(markdownStr);
+      const matchResult = matcher(markdownStr, matchedInlineParser.regexp);
       if (matchResult) {
         const matchedStr = matchResult[0];
         const matchedLength = matchedStr.length;
         const suffixStr = markdownStr.slice(matchedIndex + matchedLength);
         matchedNodeList.push({
diff --git a/web/src/labs/marked/marked.test.ts b/web/src/labs/marked/marked.test.ts
deleted file mode 100644
index 8ef893d..0000000
--- a/web/src/labs/marked/marked.test.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-/* eslint-disable no-irregular-whitespace */
-import { describe, expect, test } from "@jest/globals";
-import { unescape } from "lodash-es";
-import { marked } from ".";
-
-describe("test marked parser", () => {
-  test("horizontal rule", () => {
-    const tests = [
-      {
-        markdown: `---
-This is some text after the horizontal rule.
-___
-This is some text after the horizontal rule.
-***
-This is some text after the horizontal rule.`,
-        want: `<hr><p>This is some text after the horizontal rule.</p><hr><p>This is some text after the horizontal rule.</p><hr><p>This is some text after the horizontal rule.</p>`,
-      },
-    ];
-    for (const t of tests) {
-      expect(unescape(marked(t.markdown))).toBe(t.want);
-    }
-  });
-  test("parse code block", () => {
-    const tests = [
-      {
-        markdown: `\`\`\`
-hello world!
-\`\`\``,
-        want: `<pre><button class="codeblock-copy-btn">copy</button><code class="language-plaintext">hello world!
-</code></pre>`,
-      },
-      {
-        markdown: `test code block
-
-\`\`\`js
-console.log("hello world!")
-\`\`\``,
-        want: `<p>test code block</p><br><pre><button class="codeblock-copy-btn">copy</button><code class="language-js"><span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">"hello world!"</span>)
-</code></pre>`,
-      },
-    ];
-
-    for (const t of tests) {
-      expect(unescape(marked(t.markdown))).toBe(t.want);
-    }
-  });
-  test("parse todo list block", () => {
-    const tests = [
-      {
-        markdown: `My task:
-- [ ] finish my homework
-- [x] yahaha`,
-        want: `<p>My task:</p><p class='li-container'><span class='todo-block todo' data-value='TODO'></span><span>finish my homework</span></p><p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span><span>yahaha</span></p>`,
-      },
-    ];
-
-    for (const t of tests) {
-      expect(unescape(marked(t.markdown))).toBe(t.want);
-    }
-  });
-  test("parse list block", () => {
-    const tests = [
-      {
-        markdown: `This is a list
-* list 123
-1. 123123`,
-        want: `<p>This is a list</p><p class='li-container'><span class='ul-block'>•</span><span>list 123</span></p><p class='li-container'><span class='ol-block'>1.</span><span>123123</span></p>`,
-      },
-    ];
-
-    for (const t of tests) {
-      expect(unescape(marked(t.markdown))).toBe(t.want);
-    }
-  });
-  test("parse inline element", () => {
-    const tests = [
-      {
-        markdown: `Link: [baidu](https://baidu.com#1231)`,
-        want: `<p>Link: <a class='link' target='_blank' rel='noreferrer' href='https://baidu.com#1231'>baidu</a></p>`,
-      },
-    ];
-
-    for (const t of tests) {
-      expect(unescape(marked(t.markdown))).toBe(t.want);
-    }
-  });
-  test("parse inline code within inline element", () => {
-    const tests = [
-      {
-        markdown: `Link: [\`baidu\`](https://baidu.com)`,
-        want: `<p>Link: <a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'><code>baidu</code></a></p>`,
-      },
-    ];
-
-    for (const t of tests) {
-      expect(unescape(marked(t.markdown))).toBe(t.want);
-    }
-  });
-  test("parse plain link", () => {
-    const tests = [
-      {
-        markdown: `Link:https://baidu.com#1231`,
-        want: `<p>Link:<a class='link' target='_blank' rel='noreferrer' href='https://baidu.com#1231'>https://baidu.com#1231</a></p>`,
-      },
-    ];
-
-    for (const t of tests) {
-      expect(unescape(marked(t.markdown))).toBe(t.want);
-    }
-  });
-  test("parse inline code", () => {
-    const tests = [
-      {
-        markdown: `Code: \`console.log("Hello world!")\``,
-        want: `<p>Code: <code>console.log("Hello world!")</code></p>`,
-      },
-    ];
-
-    for (const t of tests) {
-      expect(unescape(marked(t.markdown))).toBe(t.want);
-    }
-  });
-  test("parse bold and em text", () => {
-    const tests = [
-      {
-        markdown: `Important: **Minecraft**`,
-        want: `<p>Important: <strong>Minecraft</strong></p>`,
-      },
-      {
-        markdown: `Em: *Minecraft*`,
-        want: `<p>Em: <em>Minecraft</em></p>`,
-      },
-      {
-        markdown: `Important: ***Minecraft/123***`,
-        want: `<p>Important: <strong><em>Minecraft/123</em></strong></p>`,
-      },
-      {
-        markdown: `Important: ***[baidu](https://baidu.com)***`,
-        want: `<p>Important: <strong><em><a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'>baidu</a></em></strong></p>`,
-      },
-    ];
-
-    for (const t of tests) {
-      expect(unescape(marked(t.markdown))).toBe(t.want);
-    }
-  });
-  test("parse full width space", () => {
-    const tests = [
-      {
-        markdown: `　　line1
-　　line2`,
-        want: `<p>　　line1</p><p>　　line2</p>`,
-      },
-    ];
-    for (const t of tests) {
-      expect(unescape(marked(t.markdown))).toBe(t.want);
-    }
-  });
-  test("parse heading", () => {
-    const tests = [
-      {
-        markdown: `# 123 `,
-        want: `<h1>123 </h1>`,
-      },
-      {
-        markdown: `## 123 `,
-        want: `<h2>123 </h2>`,
-      },
-    ];
-    for (const t of tests) {
-      expect(unescape(marked(t.markdown))).toBe(t.want);
-    }
-  });
-});
diff --git a/web/src/labs/marked/matcher.ts b/web/src/labs/marked/matcher.ts
new file mode 100644
index 0000000..475d809
--- /dev/null
+++ b/web/src/labs/marked/matcher.ts
@@ -0,0 +1,4 @@
+export const matcher = (rawStr: string, regexp: RegExp) => {
+  const matchResult = rawStr.match(regexp);
+  return matchResult;
+};
diff --git a/web/src/labs/marked/parser/Blockquote.ts b/web/src/labs/marked/parser/Blockquote.ts
deleted file mode 100644
index 3e1a47a..0000000
--- a/web/src/labs/marked/parser/Blockquote.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { escape } from "lodash";
-
-export const BLOCKQUOTE_REG = /^> ([^\n]+)/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(BLOCKQUOTE_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  return `<blockquote>${escape(matchResult[1])}</blockquote>`;
-};
-
-export default {
-  name: "blockquote",
-  regex: BLOCKQUOTE_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/Blockquote.tsx b/web/src/labs/marked/parser/Blockquote.tsx
new file mode 100644
index 0000000..da4d02c
--- /dev/null
+++ b/web/src/labs/marked/parser/Blockquote.tsx
@@ -0,0 +1,19 @@
+import { escape } from "lodash";
+import { matcher } from "../matcher";
+
+export const BLOCKQUOTE_REG = /^> ([^\n]+)/;
+
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, BLOCKQUOTE_REG);
+  if (!matchResult) {
+    return <>{rawStr}</>;
+  }
+
+  return <blockquote>{escape(matchResult[1])}</blockquote>;
+};
+
+export default {
+  name: "blockquote",
+  regexp: BLOCKQUOTE_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/Bold.ts b/web/src/labs/marked/parser/Bold.ts
deleted file mode 100644
index af8b875..0000000
--- a/web/src/labs/marked/parser/Bold.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { marked } from "..";
-import Link from "./Link";
-import PlainText from "./PlainText";
-
-export const BOLD_REG = /\*\*(.+?)\*\*/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(BOLD_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  const parsedContent = marked(matchResult[1], [], [Link, PlainText]);
-  return `<strong>${parsedContent}</strong>`;
-};
-
-export default {
-  name: "bold",
-  regex: BOLD_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/Bold.tsx b/web/src/labs/marked/parser/Bold.tsx
new file mode 100644
index 0000000..259eb0f
--- /dev/null
+++ b/web/src/labs/marked/parser/Bold.tsx
@@ -0,0 +1,22 @@
+import { marked } from "..";
+import { matcher } from "../matcher";
+import Link from "./Link";
+import PlainText from "./PlainText";
+
+export const BOLD_REG = /\*\*(.+?)\*\*/;
+
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, BOLD_REG);
+  if (!matchResult) {
+    return <>{rawStr}</>;
+  }
+
+  const parsedContent = marked(matchResult[1], [], [Link, PlainText]);
+  return <strong>{parsedContent}</strong>;
+};
+
+export default {
+  name: "bold",
+  regexp: BOLD_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/BoldEmphasis.ts b/web/src/labs/marked/parser/BoldEmphasis.tsx
similarity index 52%
rename from web/src/labs/marked/parser/BoldEmphasis.ts
rename to web/src/labs/marked/parser/BoldEmphasis.tsx
index 450589b..66b6eea 100644
--- a/web/src/labs/marked/parser/BoldEmphasis.ts
+++ b/web/src/labs/marked/parser/BoldEmphasis.tsx
@@ -1,27 +1,26 @@
 import { marked } from "..";
+import { matcher } from "../matcher";
 import Link from "./Link";
 import PlainText from "./PlainText";
 
 export const BOLD_EMPHASIS_REG = /\*\*\*(.+?)\*\*\*/;
 
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(BOLD_EMPHASIS_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, BOLD_EMPHASIS_REG);
   if (!matchResult) {
     return rawStr;
   }
 
   const parsedContent = marked(matchResult[1], [], [Link, PlainText]);
-  return `<strong><em>${parsedContent}</em></strong>`;
+  return (
+    <strong>
+      <em>${parsedContent}</em>
+    </strong>
+  );
 };
 
 export default {
   name: "bold emphasis",
-  regex: BOLD_EMPHASIS_REG,
-  matcher,
+  regexp: BOLD_EMPHASIS_REG,
   renderer,
 };
diff --git a/web/src/labs/marked/parser/Br.ts b/web/src/labs/marked/parser/Br.ts
deleted file mode 100644
index 0882474..0000000
--- a/web/src/labs/marked/parser/Br.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-export const BR_REG = /^(\n+)/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(BR_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  return rawStr.replaceAll("\n", "<br>");
-};
-
-export default {
-  name: "br",
-  regex: BR_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/Br.tsx b/web/src/labs/marked/parser/Br.tsx
new file mode 100644
index 0000000..9bd80ba
--- /dev/null
+++ b/web/src/labs/marked/parser/Br.tsx
@@ -0,0 +1,16 @@
+export const BR_REG = /^(\n+)/;
+
+const renderer = (rawStr: string) => {
+  const length = rawStr.split("\n").length - 1;
+  const brList = [];
+  for (let i = 0; i < length; i++) {
+    brList.push(<br />);
+  }
+  return <>{...brList}</>;
+};
+
+export default {
+  name: "br",
+  regexp: BR_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/CodeBlock.ts b/web/src/labs/marked/parser/CodeBlock.ts
deleted file mode 100644
index cb55617..0000000
--- a/web/src/labs/marked/parser/CodeBlock.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { escape } from "lodash-es";
-import hljs from "highlight.js";
-
-export const CODE_BLOCK_REG = /^```(\S*?)\s([\s\S]*?)```/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(CODE_BLOCK_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  const language = escape(matchResult[1]) || "plaintext";
-  let highlightedCode = hljs.highlightAuto(matchResult[2]).value;
-
-  try {
-    const temp = hljs.highlight(matchResult[2], {
-      language,
-    }).value;
-    highlightedCode = temp;
-  } catch (error) {
-    // do nth
-  }
-
-  return `<pre><button class="codeblock-copy-btn">copy</button><code class="language-${language}">${highlightedCode}</code></pre>`;
-};
-
-export default {
-  name: "code block",
-  regex: CODE_BLOCK_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/CodeBlock.tsx b/web/src/labs/marked/parser/CodeBlock.tsx
new file mode 100644
index 0000000..e3c4be4
--- /dev/null
+++ b/web/src/labs/marked/parser/CodeBlock.tsx
@@ -0,0 +1,51 @@
+import copy from "copy-to-clipboard";
+import { escape } from "lodash-es";
+import hljs from "highlight.js";
+import { useTranslation } from "react-i18next";
+import { matcher } from "../matcher";
+import toastHelper from "../../../components/Toast";
+
+export const CODE_BLOCK_REG = /^```(\S*?)\s([\s\S]*?)```/;
+
+const renderer = (rawStr: string) => {
+  const { t } = useTranslation();
+  const matchResult = matcher(rawStr, CODE_BLOCK_REG);
+  if (!matchResult) {
+    return <>{rawStr}</>;
+  }
+
+  const language = escape(matchResult[1]) || "plaintext";
+  let highlightedCode = hljs.highlightAuto(matchResult[2]).value;
+
+  try {
+    const temp = hljs.highlight(matchResult[2], {
+      language,
+    }).value;
+    highlightedCode = temp;
+  } catch (error) {
+    // do nth
+  }
+
+  const handleCopyButtonClick = () => {
+    copy(matchResult[2]);
+    toastHelper.success(t("message.succeed-copy-code"));
+  };
+
+  return (
+    <pre>
+      <button
+        className="text-xs font-mono italic absolute top-0 right-0 px-2 leading-6 border btn-text rounded opacity-60"
+        onClick={handleCopyButtonClick}
+      >
+        copy
+      </button>
+      <code className={`language-${language}`}>{highlightedCode}</code>
+    </pre>
+  );
+};
+
+export default {
+  name: "code block",
+  regexp: CODE_BLOCK_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/DoneList.ts b/web/src/labs/marked/parser/DoneList.ts
deleted file mode 100644
index 5faee63..0000000
--- a/web/src/labs/marked/parser/DoneList.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { inlineElementParserList } from ".";
-import { marked } from "..";
-
-export const DONE_LIST_REG = /^- \[[xX]\] ([^\n]+)/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(DONE_LIST_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
-  return `<p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span><span>${parsedContent}</span></p>`;
-};
-
-export default {
-  name: "done list",
-  regex: DONE_LIST_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/DoneList.tsx b/web/src/labs/marked/parser/DoneList.tsx
new file mode 100644
index 0000000..b3b1c10
--- /dev/null
+++ b/web/src/labs/marked/parser/DoneList.tsx
@@ -0,0 +1,28 @@
+import { inlineElementParserList } from ".";
+import { marked } from "..";
+import { matcher } from "../matcher";
+
+export const DONE_LIST_REG = /^- \[[xX]\] ([^\n]+)/;
+
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, DONE_LIST_REG);
+  if (!matchResult) {
+    return rawStr;
+  }
+
+  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
+  return (
+    <p className="li-container">
+      <span className="todo-block done" data-value="DONE">
+        ✓
+      </span>
+      <span>{parsedContent}</span>
+    </p>
+  );
+};
+
+export default {
+  name: "done list",
+  regexp: DONE_LIST_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/Emphasis.ts b/web/src/labs/marked/parser/Emphasis.tsx
similarity index 53%
rename from web/src/labs/marked/parser/Emphasis.ts
rename to web/src/labs/marked/parser/Emphasis.tsx
index b8f9126..20c2ece 100644
--- a/web/src/labs/marked/parser/Emphasis.ts
+++ b/web/src/labs/marked/parser/Emphasis.tsx
@@ -1,27 +1,22 @@
 import { marked } from "..";
+import { matcher } from "../matcher";
 import Link from "./Link";
 import PlainText from "./PlainText";
 
 export const EMPHASIS_REG = /\*(.+?)\*/;
 
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(EMPHASIS_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, EMPHASIS_REG);
   if (!matchResult) {
     return rawStr;
   }
 
   const parsedContent = marked(matchResult[1], [], [Link, PlainText]);
-  return `<em>${parsedContent}</em>`;
+  return <em>{parsedContent}</em>;
 };
 
 export default {
   name: "emphasis",
-  regex: EMPHASIS_REG,
-  matcher,
+  regexp: EMPHASIS_REG,
   renderer,
 };
diff --git a/web/src/labs/marked/parser/Heading.ts b/web/src/labs/marked/parser/Heading.ts
deleted file mode 100644
index 9f5dd2d..0000000
--- a/web/src/labs/marked/parser/Heading.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { escape } from "lodash";
-
-export const HEADING_REG = /^(#+) ([^\n]+)/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(HEADING_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  const level = matchResult[1].length;
-  return `<h${level}>${escape(matchResult[2])}</h${level}>`;
-};
-
-export default {
-  name: "heading",
-  regex: HEADING_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/Heading.tsx b/web/src/labs/marked/parser/Heading.tsx
new file mode 100644
index 0000000..82308e4
--- /dev/null
+++ b/web/src/labs/marked/parser/Heading.tsx
@@ -0,0 +1,29 @@
+import { escape } from "lodash";
+import { matcher } from "../matcher";
+
+export const HEADING_REG = /^(#+) ([^\n]+)/;
+
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, HEADING_REG);
+  if (!matchResult) {
+    return rawStr;
+  }
+
+  const level = matchResult[1].length;
+  if (level === 1) {
+    return <h1>{escape(matchResult[2])}</h1>;
+  } else if (level === 2) {
+    return <h2>{escape(matchResult[2])}</h2>;
+  } else if (level === 3) {
+    return <h3>{escape(matchResult[2])}</h3>;
+  } else if (level === 4) {
+    return <h4>{escape(matchResult[2])}</h4>;
+  }
+  return <h5>{escape(matchResult[2])}</h5>;
+};
+
+export default {
+  name: "heading",
+  regexp: HEADING_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/HorizontalRules.ts b/web/src/labs/marked/parser/HorizontalRules.ts
deleted file mode 100644
index a52a519..0000000
--- a/web/src/labs/marked/parser/HorizontalRules.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export const HORIZONTAL_RULES_REG = /^_{3}|^-{3}|^\*{3}/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(HORIZONTAL_RULES_REG);
-  return matchResult;
-};
-
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const renderer = (rawStr: string): string => {
-  return `<hr>`;
-};
-
-export default {
-  name: "horizontal rules",
-  regex: HORIZONTAL_RULES_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/HorizontalRules.tsx b/web/src/labs/marked/parser/HorizontalRules.tsx
new file mode 100644
index 0000000..e8f1486
--- /dev/null
+++ b/web/src/labs/marked/parser/HorizontalRules.tsx
@@ -0,0 +1,12 @@
+export const HORIZONTAL_RULES_REG = /^_{3}|^-{3}|^\*{3}/;
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const renderer = (rawStr: string) => {
+  return <hr />;
+};
+
+export default {
+  name: "horizontal rules",
+  regexp: HORIZONTAL_RULES_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/Image.ts b/web/src/labs/marked/parser/Image.tsx
similarity index 51%
rename from web/src/labs/marked/parser/Image.ts
rename to web/src/labs/marked/parser/Image.tsx
index b3afc50..81052a3 100644
--- a/web/src/labs/marked/parser/Image.ts
+++ b/web/src/labs/marked/parser/Image.tsx
@@ -1,26 +1,21 @@
 import { escape } from "lodash-es";
 import { absolutifyLink } from "../../../helpers/utils";
+import { matcher } from "../matcher";
 
 export const IMAGE_REG = /!\[.*?\]\((.+?)\)/;
 
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(IMAGE_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, IMAGE_REG);
   if (!matchResult) {
     return rawStr;
   }
 
   const imageUrl = absolutifyLink(escape(matchResult[1]));
-  return `<img class='img' src='${imageUrl}' />`;
+  return <img className="img" src={imageUrl} />;
 };
 
 export default {
   name: "image",
-  regex: IMAGE_REG,
-  matcher,
+  regexp: IMAGE_REG,
   renderer,
 };
diff --git a/web/src/labs/marked/parser/InlineCode.ts b/web/src/labs/marked/parser/InlineCode.ts
deleted file mode 100644
index 7de2a1f..0000000
--- a/web/src/labs/marked/parser/InlineCode.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { escape } from "lodash-es";
-
-export const INLINE_CODE_REG = /`(.+?)`/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(INLINE_CODE_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  return `<code>${escape(matchResult[1])}</code>`;
-};
-
-export default {
-  name: "inline code",
-  regex: INLINE_CODE_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/InlineCode.tsx b/web/src/labs/marked/parser/InlineCode.tsx
new file mode 100644
index 0000000..0c7c90d
--- /dev/null
+++ b/web/src/labs/marked/parser/InlineCode.tsx
@@ -0,0 +1,19 @@
+import { escape } from "lodash-es";
+import { matcher } from "../matcher";
+
+export const INLINE_CODE_REG = /`(.+?)`/;
+
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, INLINE_CODE_REG);
+  if (!matchResult) {
+    return rawStr;
+  }
+
+  return <code>{escape(matchResult[1])}</code>;
+};
+
+export default {
+  name: "inline code",
+  regexp: INLINE_CODE_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/Link.ts b/web/src/labs/marked/parser/Link.tsx
similarity index 59%
rename from web/src/labs/marked/parser/Link.ts
rename to web/src/labs/marked/parser/Link.tsx
index 89c4ae6..3ff7ada 100644
--- a/web/src/labs/marked/parser/Link.ts
+++ b/web/src/labs/marked/parser/Link.tsx
@@ -3,28 +3,27 @@ import Emphasis from "./Emphasis";
 import Bold from "./Bold";
 import { marked } from "..";
 import InlineCode from "./InlineCode";
 import BoldEmphasis from "./BoldEmphasis";
 import PlainText from "./PlainText";
+import { matcher } from "../matcher";
 
 export const LINK_REG = /\[(.*?)\]\((.+?)\)+/;
 
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(LINK_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, LINK_REG);
   if (!matchResult) {
     return rawStr;
   }
   const parsedContent = marked(matchResult[1], [], [InlineCode, BoldEmphasis, Emphasis, Bold, PlainText]);
-  return `<a class='link' target='_blank' rel='noreferrer' href='${escape(matchResult[2])}'>${parsedContent}</a>`;
+  return (
+    <a className="link" target="_blank" rel="noreferrer" href={escape(matchResult[2])}>
+      {parsedContent}
+    </a>
+  );
 };
 
 export default {
   name: "link",
-  regex: LINK_REG,
-  matcher,
+  regexp: LINK_REG,
   renderer,
 };
diff --git a/web/src/labs/marked/parser/OrderedList.ts b/web/src/labs/marked/parser/OrderedList.ts
deleted file mode 100644
index 3f7f3fb..0000000
--- a/web/src/labs/marked/parser/OrderedList.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { inlineElementParserList } from ".";
-import { marked } from "..";
-
-export const ORDERED_LIST_REG = /^(\d+)\. (.+)/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(ORDERED_LIST_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  const parsedContent = marked(matchResult[2], [], inlineElementParserList);
-  return `<p class='li-container'><span class='ol-block'>${matchResult[1]}.</span><span>${parsedContent}</span></p>`;
-};
-
-export default {
-  name: "ordered list",
-  regex: ORDERED_LIST_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/OrderedList.tsx b/web/src/labs/marked/parser/OrderedList.tsx
new file mode 100644
index 0000000..ebe0dc9
--- /dev/null
+++ b/web/src/labs/marked/parser/OrderedList.tsx
@@ -0,0 +1,26 @@
+import { inlineElementParserList } from ".";
+import { marked } from "..";
+import { matcher } from "../matcher";
+
+export const ORDERED_LIST_REG = /^(\d+)\. (.+)/;
+
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, ORDERED_LIST_REG);
+  if (!matchResult) {
+    return rawStr;
+  }
+
+  const parsedContent = marked(matchResult[2], [], inlineElementParserList);
+  return (
+    <p className="li-container">
+      <span className="ol-block">{matchResult[1]}.</span>
+      <span>{parsedContent}</span>
+    </p>
+  );
+};
+
+export default {
+  name: "ordered list",
+  regexp: ORDERED_LIST_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/Paragraph.ts b/web/src/labs/marked/parser/Paragraph.tsx
similarity index 51%
rename from web/src/labs/marked/parser/Paragraph.ts
rename to web/src/labs/marked/parser/Paragraph.tsx
index bc22d8c..60cb69d 100644
--- a/web/src/labs/marked/parser/Paragraph.ts
+++ b/web/src/labs/marked/parser/Paragraph.tsx
@@ -1,21 +1,15 @@
 import { inlineElementParserList } from ".";
 import { marked } from "..";
 
 export const PARAGRAPH_REG = /^([^\n]+)/;
 
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(PARAGRAPH_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
+const renderer = (rawStr: string) => {
   const parsedContent = marked(rawStr, [], inlineElementParserList);
-  return `<p>${parsedContent}</p>`;
+  return <p>{parsedContent}</p>;
 };
 
 export default {
   name: "paragraph",
-  regex: PARAGRAPH_REG,
-  matcher,
+  regexp: PARAGRAPH_REG,
   renderer,
 };
diff --git a/web/src/labs/marked/parser/PlainLink.ts b/web/src/labs/marked/parser/PlainLink.ts
deleted file mode 100644
index 3af201a..0000000
--- a/web/src/labs/marked/parser/PlainLink.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { escape } from "lodash-es";
-
-export const PLAIN_LINK_REG = /(https?:\/\/[^ ]+)/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(PLAIN_LINK_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  return `<a class='link' target='_blank' rel='noreferrer' href='${escape(matchResult[1])}'>${escape(matchResult[1])}</a>`;
-};
-
-export default {
-  name: "plain link",
-  regex: PLAIN_LINK_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/PlainLink.tsx b/web/src/labs/marked/parser/PlainLink.tsx
new file mode 100644
index 0000000..f420fbe
--- /dev/null
+++ b/web/src/labs/marked/parser/PlainLink.tsx
@@ -0,0 +1,23 @@
+import { escape } from "lodash-es";
+import { matcher } from "../matcher";
+
+export const PLAIN_LINK_REG = /(https?:\/\/[^ ]+)/;
+
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, PLAIN_LINK_REG);
+  if (!matchResult) {
+    return rawStr;
+  }
+
+  return (
+    <a className="link" target="_blank" rel="noreferrer" href={escape(matchResult[1])}>
+      {escape(matchResult[1])}
+    </a>
+  );
+};
+
+export default {
+  name: "plain link",
+  regexp: PLAIN_LINK_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/PlainText.ts b/web/src/labs/marked/parser/PlainText.tsx
similarity index 58%
rename from web/src/labs/marked/parser/PlainText.ts
rename to web/src/labs/marked/parser/PlainText.tsx
index 507c979..498c7b5 100644
--- a/web/src/labs/marked/parser/PlainText.ts
+++ b/web/src/labs/marked/parser/PlainText.tsx
@@ -1,24 +1,19 @@
 import { escape } from "lodash-es";
+import { matcher } from "../matcher";
 
 export const PLAIN_TEXT_REG = /(.+)/;
 
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(PLAIN_TEXT_REG);
-  return matchResult;
-};
-
 const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
+  const matchResult = matcher(rawStr, PLAIN_TEXT_REG);
   if (!matchResult) {
     return rawStr;
   }
 
   return `${escape(matchResult[1])}`;
 };
 
 export default {
   name: "plain text",
-  regex: PLAIN_TEXT_REG,
-  matcher,
+  regexp: PLAIN_TEXT_REG,
   renderer,
 };
diff --git a/web/src/labs/marked/parser/Strikethrough.ts b/web/src/labs/marked/parser/Strikethrough.ts
deleted file mode 100644
index 7dc1897..0000000
--- a/web/src/labs/marked/parser/Strikethrough.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { escape } from "lodash";
-
-export const STRIKETHROUGH_REG = /~~(.+?)~~/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(STRIKETHROUGH_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  return `<del>${escape(matchResult[1])}</del>`;
-};
-
-export default {
-  name: "Strikethrough",
-  regex: STRIKETHROUGH_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/Strikethrough.tsx b/web/src/labs/marked/parser/Strikethrough.tsx
new file mode 100644
index 0000000..5b0cb98
--- /dev/null
+++ b/web/src/labs/marked/parser/Strikethrough.tsx
@@ -0,0 +1,19 @@
+import { escape } from "lodash";
+import { matcher } from "../matcher";
+
+export const STRIKETHROUGH_REG = /~~(.+?)~~/;
+
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, STRIKETHROUGH_REG);
+  if (!matchResult) {
+    return rawStr;
+  }
+
+  return <del>{escape(matchResult[1])}</del>;
+};
+
+export default {
+  name: "Strikethrough",
+  regexp: STRIKETHROUGH_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/Tag.ts b/web/src/labs/marked/parser/Tag.ts
deleted file mode 100644
index a0fc5ff..0000000
--- a/web/src/labs/marked/parser/Tag.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { escape } from "lodash-es";
-
-export const TAG_REG = /#([^\s#]+)/;
-
-export const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(TAG_REG);
-  if (matchResult) {
-    return matchResult;
-  }
-  return null;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  return `<span class='tag-span'>#${escape(matchResult[1])}</span>`;
-};
-
-export default {
-  name: "tag",
-  regex: TAG_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/Tag.tsx b/web/src/labs/marked/parser/Tag.tsx
new file mode 100644
index 0000000..b7f1b5f
--- /dev/null
+++ b/web/src/labs/marked/parser/Tag.tsx
@@ -0,0 +1,19 @@
+import { escape } from "lodash-es";
+import { matcher } from "../matcher";
+
+export const TAG_REG = /#([^\s#]+)/;
+
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, TAG_REG);
+  if (!matchResult) {
+    return rawStr;
+  }
+
+  return <span className="tag-span">#{escape(matchResult[1])}</span>;
+};
+
+export default {
+  name: "tag",
+  regexp: TAG_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/TodoList.ts b/web/src/labs/marked/parser/TodoList.ts
deleted file mode 100644
index 4d552ff..0000000
--- a/web/src/labs/marked/parser/TodoList.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { inlineElementParserList } from ".";
-import { marked } from "..";
-
-export const TODO_LIST_REG = /^- \[ \] ([^\n]+)/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(TODO_LIST_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
-  return `<p class='li-container'><span class='todo-block todo' data-value='TODO'></span><span>${parsedContent}</span></p>`;
-};
-
-export default {
-  name: "todo list",
-  regex: TODO_LIST_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/TodoList.tsx b/web/src/labs/marked/parser/TodoList.tsx
new file mode 100644
index 0000000..10aa4ff
--- /dev/null
+++ b/web/src/labs/marked/parser/TodoList.tsx
@@ -0,0 +1,26 @@
+import { inlineElementParserList } from ".";
+import { marked } from "..";
+import { matcher } from "../matcher";
+
+export const TODO_LIST_REG = /^- \[ \] ([^\n]+)/;
+
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, TODO_LIST_REG);
+  if (!matchResult) {
+    return rawStr;
+  }
+
+  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
+  return (
+    <p className="li-container">
+      <span className="todo-block todo" data-value="TODO"></span>
+      <span>{parsedContent}</span>
+    </p>
+  );
+};
+
+export default {
+  name: "todo list",
+  regexp: TODO_LIST_REG,
+  renderer,
+};
diff --git a/web/src/labs/marked/parser/UnorderedList.ts b/web/src/labs/marked/parser/UnorderedList.ts
deleted file mode 100644
index 2903982..0000000
--- a/web/src/labs/marked/parser/UnorderedList.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { inlineElementParserList } from ".";
-import { marked } from "..";
-
-export const UNORDERED_LIST_REG = /^[*-] ([^\n]+)/;
-
-const matcher = (rawStr: string) => {
-  const matchResult = rawStr.match(UNORDERED_LIST_REG);
-  return matchResult;
-};
-
-const renderer = (rawStr: string): string => {
-  const matchResult = matcher(rawStr);
-  if (!matchResult) {
-    return rawStr;
-  }
-
-  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
-  return `<p class='li-container'><span class='ul-block'>•</span><span>${parsedContent}</span></p>`;
-};
-
-export default {
-  name: "unordered list",
-  regex: UNORDERED_LIST_REG,
-  matcher,
-  renderer,
-};
diff --git a/web/src/labs/marked/parser/UnorderedList.tsx b/web/src/labs/marked/parser/UnorderedList.tsx
new file mode 100644
index 0000000..595d6e1
--- /dev/null
+++ b/web/src/labs/marked/parser/UnorderedList.tsx
@@ -0,0 +1,26 @@
+import { inlineElementParserList } from ".";
+import { marked } from "..";
+import { matcher } from "../matcher";
+
+export const UNORDERED_LIST_REG = /^[*-] ([^\n]+)/;
+
+const renderer = (rawStr: string) => {
+  const matchResult = matcher(rawStr, UNORDERED_LIST_REG);
+  if (!matchResult) {
+    return rawStr;
+  }
+
+  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
+  return (
+    <p className="li-container">
+      <span className="ul-block">•</span>
+      <span>${parsedContent}</span>
+    </p>
+  );
+};
+
+export default {
+  name: "unordered list",
+  regexp: UNORDERED_LIST_REG,
+  renderer,
+};

Vulnerable code:   const matchResult = Tag.matcher(`#${tagName}`);
  highlightWord?: string;
  const { memo, highlightWord } = props;
    } else if (targetEl.tagName === "BUTTON" && targetEl.className === "codeblock-copy-btn") {
      copy(targetEl.parentElement?.children[1].textContent ?? "");
      toastHelper.success(t("message.succeed-copy-code"));
        highlightWord={highlightWord}
import { highlightWithWord } from "../labs/highlighter";
  highlightWord?: string;
  const { className, content, highlightWord, onMemoContentClick, onMemoContentDoubleClick } = props;
  const { t } = useTranslation();
  const userStore = useUserStore();
  const user = userStore.state.user;
        dangerouslySetInnerHTML={{
          __html: highlightWithWord(marked(state.expandButtonStatus === 0 ? foldedContent : content), highlightWord),
        }}
      ></div>
  const [highlightWord, setHighlightWord] = useState<string | undefined>("");
    setHighlightWord(query?.text);
        <Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} highlightWord={highlightWord} />
import { escape } from "lodash";

const walkthroughNodeWithKeyword = (node: HTMLElement, keyword: string) => {
  if (node.nodeType === 3) {
    const span = document.createElement("span");
    span.innerHTML = node.nodeValue?.replace(new RegExp(keyword, "g"), `<mark>${keyword}</mark>`) ?? "";
    node.parentNode?.insertBefore(span, node);
    node.parentNode?.removeChild(node);
  }
  for (const child of Array.from(node.childNodes)) {
    walkthroughNodeWithKeyword(<HTMLElement>child, keyword);
  }
  return node.innerHTML;
};

export const highlightWithWord = (html: string, keyword?: string): string => {
  if (!keyword) {
    return html;
  }
  keyword = escape(keyword);
  const wrap = document.createElement("div");
  wrap.innerHTML = escape(html);
  return walkthroughNodeWithKeyword(wrap, keyword);
};
export const marked = (markdownStr: string, blockParsers = blockElementParserList, inlineParsers = inlineElementParserList): string => {
    const matchResult = parser.matcher(markdownStr);
      return parser.renderer(matchedStr) + marked(retainContent, blockParsers, inlineParsers);
        return parser.renderer(matchedStr) + marked(retainContent.slice(1), blockParsers, inlineParsers);
    const matchResult = parser.matcher(markdownStr);
    const matchResult = matchedInlineParser.matcher(markdownStr);
      return marked(prefixStr, [], inlineParsers) + matchedInlineParser.renderer(matchedStr) + marked(suffixStr, [], inlineParsers);
  return markdownStr;
      const matchResult = parser.matcher(markdownStr);
      const matchResult = parser.matcher(markdownStr);
      const matchResult = matchedInlineParser.matcher(markdownStr);
/* eslint-disable no-irregular-whitespace */
import { describe, expect, test } from "@jest/globals";
import { unescape } from "lodash-es";
import { marked } from ".";

describe("test marked parser", () => {
  test("horizontal rule", () => {
    const tests = [
      {
        markdown: `---
This is some text after the horizontal rule.
___
This is some text after the horizontal rule.
***
This is some text after the horizontal rule.`,
        want: `<hr><p>This is some text after the horizontal rule.</p><hr><p>This is some text after the horizontal rule.</p><hr><p>This is some text after the horizontal rule.</p>`,
      },
    ];
    for (const t of tests) {
      expect(unescape(marked(t.markdown))).toBe(t.want);
    }
  });
  test("parse code block", () => {
    const tests = [
      {
        markdown: `\`\`\`
hello world!
\`\`\``,
        want: `<pre><button class="codeblock-copy-btn">copy</button><code class="language-plaintext">hello world!
</code></pre>`,
      },
      {
        markdown: `test code block

\`\`\`js
console.log("hello world!")
\`\`\``,
        want: `<p>test code block</p><br><pre><button class="codeblock-copy-btn">copy</button><code class="language-js"><span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">"hello world!"</span>)
</code></pre>`,
      },
    ];

    for (const t of tests) {
      expect(unescape(marked(t.markdown))).toBe(t.want);
    }
  });
  test("parse todo list block", () => {
    const tests = [
      {
        markdown: `My task:
- [ ] finish my homework
- [x] yahaha`,
        want: `<p>My task:</p><p class='li-container'><span class='todo-block todo' data-value='TODO'></span><span>finish my homework</span></p><p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span><span>yahaha</span></p>`,
      },
    ];

    for (const t of tests) {
      expect(unescape(marked(t.markdown))).toBe(t.want);
    }
  });
  test("parse list block", () => {
    const tests = [
      {
        markdown: `This is a list
* list 123
1. 123123`,
        want: `<p>This is a list</p><p class='li-container'><span class='ul-block'>•</span><span>list 123</span></p><p class='li-container'><span class='ol-block'>1.</span><span>123123</span></p>`,
      },
    ];

    for (const t of tests) {
      expect(unescape(marked(t.markdown))).toBe(t.want);
    }
  });
  test("parse inline element", () => {
    const tests = [
      {
        markdown: `Link: [baidu](https://baidu.com#1231)`,
        want: `<p>Link: <a class='link' target='_blank' rel='noreferrer' href='https://baidu.com#1231'>baidu</a></p>`,
      },
    ];

    for (const t of tests) {
      expect(unescape(marked(t.markdown))).toBe(t.want);
    }
  });
  test("parse inline code within inline element", () => {
    const tests = [
      {
        markdown: `Link: [\`baidu\`](https://baidu.com)`,
        want: `<p>Link: <a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'><code>baidu</code></a></p>`,
      },
    ];

    for (const t of tests) {
      expect(unescape(marked(t.markdown))).toBe(t.want);
    }
  });
  test("parse plain link", () => {
    const tests = [
      {
        markdown: `Link:https://baidu.com#1231`,
        want: `<p>Link:<a class='link' target='_blank' rel='noreferrer' href='https://baidu.com#1231'>https://baidu.com#1231</a></p>`,
      },
    ];

    for (const t of tests) {
      expect(unescape(marked(t.markdown))).toBe(t.want);
    }
  });
  test("parse inline code", () => {
    const tests = [
      {
        markdown: `Code: \`console.log("Hello world!")\``,
        want: `<p>Code: <code>console.log("Hello world!")</code></p>`,
      },
    ];

    for (const t of tests) {
      expect(unescape(marked(t.markdown))).toBe(t.want);
    }
  });
  test("parse bold and em text", () => {
    const tests = [
      {
        markdown: `Important: **Minecraft**`,
        want: `<p>Important: <strong>Minecraft</strong></p>`,
      },
      {
        markdown: `Em: *Minecraft*`,
        want: `<p>Em: <em>Minecraft</em></p>`,
      },
      {
        markdown: `Important: ***Minecraft/123***`,
        want: `<p>Important: <strong><em>Minecraft/123</em></strong></p>`,
      },
      {
        markdown: `Important: ***[baidu](https://baidu.com)***`,
        want: `<p>Important: <strong><em><a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'>baidu</a></em></strong></p>`,
      },
    ];

    for (const t of tests) {
      expect(unescape(marked(t.markdown))).toBe(t.want);
    }
  });
  test("parse full width space", () => {
    const tests = [
      {
        markdown: `　　line1
　　line2`,
        want: `<p>　　line1</p><p>　　line2</p>`,
      },
    ];
    for (const t of tests) {
      expect(unescape(marked(t.markdown))).toBe(t.want);
    }
  });
  test("parse heading", () => {
    const tests = [
      {
        markdown: `# 123 `,
        want: `<h1>123 </h1>`,
      },
      {
        markdown: `## 123 `,
        want: `<h2>123 </h2>`,
      },
    ];
    for (const t of tests) {
      expect(unescape(marked(t.markdown))).toBe(t.want);
    }
  });
});
import { escape } from "lodash";

export const BLOCKQUOTE_REG = /^> ([^\n]+)/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(BLOCKQUOTE_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  return `<blockquote>${escape(matchResult[1])}</blockquote>`;
};

export default {
  name: "blockquote",
  regex: BLOCKQUOTE_REG,
  matcher,
  renderer,
};
import { marked } from "..";
import Link from "./Link";
import PlainText from "./PlainText";

export const BOLD_REG = /\*\*(.+?)\*\*/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(BOLD_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  const parsedContent = marked(matchResult[1], [], [Link, PlainText]);
  return `<strong>${parsedContent}</strong>`;
};

export default {
  name: "bold",
  regex: BOLD_REG,
  matcher,
  renderer,
};
const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(BOLD_EMPHASIS_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  return `<strong><em>${parsedContent}</em></strong>`;
  regex: BOLD_EMPHASIS_REG,
  matcher,
export const BR_REG = /^(\n+)/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(BR_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  return rawStr.replaceAll("\n", "<br>");
};

export default {
  name: "br",
  regex: BR_REG,
  matcher,
  renderer,
};
import { escape } from "lodash-es";
import hljs from "highlight.js";

export const CODE_BLOCK_REG = /^```(\S*?)\s([\s\S]*?)```/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(CODE_BLOCK_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  const language = escape(matchResult[1]) || "plaintext";
  let highlightedCode = hljs.highlightAuto(matchResult[2]).value;

  try {
    const temp = hljs.highlight(matchResult[2], {
      language,
    }).value;
    highlightedCode = temp;
  } catch (error) {
    // do nth
  }

  return `<pre><button class="codeblock-copy-btn">copy</button><code class="language-${language}">${highlightedCode}</code></pre>`;
};

export default {
  name: "code block",
  regex: CODE_BLOCK_REG,
  matcher,
  renderer,
};
import { inlineElementParserList } from ".";
import { marked } from "..";

export const DONE_LIST_REG = /^- \[[xX]\] ([^\n]+)/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(DONE_LIST_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
  return `<p class='li-container'><span class='todo-block done' data-value='DONE'>✓</span><span>${parsedContent}</span></p>`;
};

export default {
  name: "done list",
  regex: DONE_LIST_REG,
  matcher,
  renderer,
};
const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(EMPHASIS_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  return `<em>${parsedContent}</em>`;
  regex: EMPHASIS_REG,
  matcher,
import { escape } from "lodash";

export const HEADING_REG = /^(#+) ([^\n]+)/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(HEADING_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  const level = matchResult[1].length;
  return `<h${level}>${escape(matchResult[2])}</h${level}>`;
};

export default {
  name: "heading",
  regex: HEADING_REG,
  matcher,
  renderer,
};
export const HORIZONTAL_RULES_REG = /^_{3}|^-{3}|^\*{3}/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(HORIZONTAL_RULES_REG);
  return matchResult;
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const renderer = (rawStr: string): string => {
  return `<hr>`;
};

export default {
  name: "horizontal rules",
  regex: HORIZONTAL_RULES_REG,
  matcher,
  renderer,
};
const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(IMAGE_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  return `<img class='img' src='${imageUrl}' />`;
  regex: IMAGE_REG,
  matcher,
import { escape } from "lodash-es";

export const INLINE_CODE_REG = /`(.+?)`/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(INLINE_CODE_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  return `<code>${escape(matchResult[1])}</code>`;
};

export default {
  name: "inline code",
  regex: INLINE_CODE_REG,
  matcher,
  renderer,
};
const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(LINK_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  return `<a class='link' target='_blank' rel='noreferrer' href='${escape(matchResult[2])}'>${parsedContent}</a>`;
  regex: LINK_REG,
  matcher,
import { inlineElementParserList } from ".";
import { marked } from "..";

export const ORDERED_LIST_REG = /^(\d+)\. (.+)/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(ORDERED_LIST_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  const parsedContent = marked(matchResult[2], [], inlineElementParserList);
  return `<p class='li-container'><span class='ol-block'>${matchResult[1]}.</span><span>${parsedContent}</span></p>`;
};

export default {
  name: "ordered list",
  regex: ORDERED_LIST_REG,
  matcher,
  renderer,
};
const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(PARAGRAPH_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  return `<p>${parsedContent}</p>`;
  regex: PARAGRAPH_REG,
  matcher,
import { escape } from "lodash-es";

export const PLAIN_LINK_REG = /(https?:\/\/[^ ]+)/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(PLAIN_LINK_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  return `<a class='link' target='_blank' rel='noreferrer' href='${escape(matchResult[1])}'>${escape(matchResult[1])}</a>`;
};

export default {
  name: "plain link",
  regex: PLAIN_LINK_REG,
  matcher,
  renderer,
};
const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(PLAIN_TEXT_REG);
  return matchResult;
};

  const matchResult = matcher(rawStr);
  regex: PLAIN_TEXT_REG,
  matcher,
import { escape } from "lodash";

export const STRIKETHROUGH_REG = /~~(.+?)~~/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(STRIKETHROUGH_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  return `<del>${escape(matchResult[1])}</del>`;
};

export default {
  name: "Strikethrough",
  regex: STRIKETHROUGH_REG,
  matcher,
  renderer,
};
import { escape } from "lodash-es";

export const TAG_REG = /#([^\s#]+)/;

export const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(TAG_REG);
  if (matchResult) {
    return matchResult;
  }
  return null;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  return `<span class='tag-span'>#${escape(matchResult[1])}</span>`;
};

export default {
  name: "tag",
  regex: TAG_REG,
  matcher,
  renderer,
};
import { inlineElementParserList } from ".";
import { marked } from "..";

export const TODO_LIST_REG = /^- \[ \] ([^\n]+)/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(TODO_LIST_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
  return `<p class='li-container'><span class='todo-block todo' data-value='TODO'></span><span>${parsedContent}</span></p>`;
};

export default {
  name: "todo list",
  regex: TODO_LIST_REG,
  matcher,
  renderer,
};
import { inlineElementParserList } from ".";
import { marked } from "..";

export const UNORDERED_LIST_REG = /^[*-] ([^\n]+)/;

const matcher = (rawStr: string) => {
  const matchResult = rawStr.match(UNORDERED_LIST_REG);
  return matchResult;
};

const renderer = (rawStr: string): string => {
  const matchResult = matcher(rawStr);
  if (!matchResult) {
    return rawStr;
  }

  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
  return `<p class='li-container'><span class='ul-block'>•</span><span>${parsedContent}</span></p>`;
};

export default {
  name: "unordered list",
  regex: UNORDERED_LIST_REG,
  matcher,
  renderer,
};
Fixed code: import { matcher } from "../labs/marked/matcher";
  const matchResult = matcher(`#${tagName}`, Tag.regexp);
  const { memo } = props;
  const { className, content, onMemoContentClick, onMemoContentDoubleClick } = props;
  const { t } = useTranslation();
  const userStore = useUserStore();
  const user = userStore.state.user;
      >
        {marked(state.expandButtonStatus === 0 ? foldedContent : content)}
      </div>
        <Memo key={`${memo.id}-${memo.displayTs}`} memo={memo} />
import { matcher } from "./matcher";
export const marked = (
  markdownStr: string,
  blockParsers = blockElementParserList,
  inlineParsers = inlineElementParserList
): string | JSX.Element => {
    const matchResult = matcher(markdownStr, parser.regexp);
      return (
        <>
          {parser.renderer(matchedStr)}
          {marked(retainContent, blockParsers, inlineParsers)}
        </>
      );
        return (
          <>
            {parser.renderer(matchedStr)}
            {marked(retainContent.slice(1), blockParsers, inlineParsers)}
          </>
        );
    const matchResult = matcher(markdownStr, parser.regexp);
    const matchResult = matcher(markdownStr, matchedInlineParser.regexp);
      return (
        <>
          {marked(prefixStr, [], inlineParsers)}
          {matchedInlineParser.renderer(matchedStr)}
          {marked(suffixStr, [], inlineParsers)}
        </>
      );
  return <>{markdownStr}</>;
      const matchResult = matcher(markdownStr, parser.regexp);
          walkthough(matchedStr, [], inlineParsers);
      const matchResult = matcher(markdownStr, parser.regexp);
      const matchResult = matcher(markdownStr, matchedInlineParser.regexp);
export const matcher = (rawStr: string, regexp: RegExp) => {
  const matchResult = rawStr.match(regexp);
  return matchResult;
};
import { escape } from "lodash";
import { matcher } from "../matcher";

export const BLOCKQUOTE_REG = /^> ([^\n]+)/;

const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, BLOCKQUOTE_REG);
  if (!matchResult) {
    return <>{rawStr}</>;
  }

  return <blockquote>{escape(matchResult[1])}</blockquote>;
};

export default {
  name: "blockquote",
  regexp: BLOCKQUOTE_REG,
  renderer,
};
import { marked } from "..";
import { matcher } from "../matcher";
import Link from "./Link";
import PlainText from "./PlainText";

export const BOLD_REG = /\*\*(.+?)\*\*/;

const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, BOLD_REG);
  if (!matchResult) {
    return <>{rawStr}</>;
  }

  const parsedContent = marked(matchResult[1], [], [Link, PlainText]);
  return <strong>{parsedContent}</strong>;
};

export default {
  name: "bold",
  regexp: BOLD_REG,
  renderer,
};
import { matcher } from "../matcher";
const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, BOLD_EMPHASIS_REG);
  return (
    <strong>
      <em>${parsedContent}</em>
    </strong>
  );
  regexp: BOLD_EMPHASIS_REG,
export const BR_REG = /^(\n+)/;

const renderer = (rawStr: string) => {
  const length = rawStr.split("\n").length - 1;
  const brList = [];
  for (let i = 0; i < length; i++) {
    brList.push(<br />);
  }
  return <>{...brList}</>;
};

export default {
  name: "br",
  regexp: BR_REG,
  renderer,
};
import copy from "copy-to-clipboard";
import { escape } from "lodash-es";
import hljs from "highlight.js";
import { useTranslation } from "react-i18next";
import { matcher } from "../matcher";
import toastHelper from "../../../components/Toast";

export const CODE_BLOCK_REG = /^```(\S*?)\s([\s\S]*?)```/;

const renderer = (rawStr: string) => {
  const { t } = useTranslation();
  const matchResult = matcher(rawStr, CODE_BLOCK_REG);
  if (!matchResult) {
    return <>{rawStr}</>;
  }

  const language = escape(matchResult[1]) || "plaintext";
  let highlightedCode = hljs.highlightAuto(matchResult[2]).value;

  try {
    const temp = hljs.highlight(matchResult[2], {
      language,
    }).value;
    highlightedCode = temp;
  } catch (error) {
    // do nth
  }

  const handleCopyButtonClick = () => {
    copy(matchResult[2]);
    toastHelper.success(t("message.succeed-copy-code"));
  };

  return (
    <pre>
      <button
        className="text-xs font-mono italic absolute top-0 right-0 px-2 leading-6 border btn-text rounded opacity-60"
        onClick={handleCopyButtonClick}
      >
        copy
      </button>
      <code className={`language-${language}`}>{highlightedCode}</code>
    </pre>
  );
};

export default {
  name: "code block",
  regexp: CODE_BLOCK_REG,
  renderer,
};
import { inlineElementParserList } from ".";
import { marked } from "..";
import { matcher } from "../matcher";

export const DONE_LIST_REG = /^- \[[xX]\] ([^\n]+)/;

const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, DONE_LIST_REG);
  if (!matchResult) {
    return rawStr;
  }

  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
  return (
    <p className="li-container">
      <span className="todo-block done" data-value="DONE">
        ✓
      </span>
      <span>{parsedContent}</span>
    </p>
  );
};

export default {
  name: "done list",
  regexp: DONE_LIST_REG,
  renderer,
};
import { matcher } from "../matcher";
const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, EMPHASIS_REG);
  return <em>{parsedContent}</em>;
  regexp: EMPHASIS_REG,
import { escape } from "lodash";
import { matcher } from "../matcher";

export const HEADING_REG = /^(#+) ([^\n]+)/;

const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, HEADING_REG);
  if (!matchResult) {
    return rawStr;
  }

  const level = matchResult[1].length;
  if (level === 1) {
    return <h1>{escape(matchResult[2])}</h1>;
  } else if (level === 2) {
    return <h2>{escape(matchResult[2])}</h2>;
  } else if (level === 3) {
    return <h3>{escape(matchResult[2])}</h3>;
  } else if (level === 4) {
    return <h4>{escape(matchResult[2])}</h4>;
  }
  return <h5>{escape(matchResult[2])}</h5>;
};

export default {
  name: "heading",
  regexp: HEADING_REG,
  renderer,
};
export const HORIZONTAL_RULES_REG = /^_{3}|^-{3}|^\*{3}/;

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const renderer = (rawStr: string) => {
  return <hr />;
};

export default {
  name: "horizontal rules",
  regexp: HORIZONTAL_RULES_REG,
  renderer,
};
import { matcher } from "../matcher";
const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, IMAGE_REG);
  return <img className="img" src={imageUrl} />;
  regexp: IMAGE_REG,
import { escape } from "lodash-es";
import { matcher } from "../matcher";

export const INLINE_CODE_REG = /`(.+?)`/;

const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, INLINE_CODE_REG);
  if (!matchResult) {
    return rawStr;
  }

  return <code>{escape(matchResult[1])}</code>;
};

export default {
  name: "inline code",
  regexp: INLINE_CODE_REG,
  renderer,
};
import { matcher } from "../matcher";
const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, LINK_REG);
  return (
    <a className="link" target="_blank" rel="noreferrer" href={escape(matchResult[2])}>
      {parsedContent}
    </a>
  );
  regexp: LINK_REG,
import { inlineElementParserList } from ".";
import { marked } from "..";
import { matcher } from "../matcher";

export const ORDERED_LIST_REG = /^(\d+)\. (.+)/;

const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, ORDERED_LIST_REG);
  if (!matchResult) {
    return rawStr;
  }

  const parsedContent = marked(matchResult[2], [], inlineElementParserList);
  return (
    <p className="li-container">
      <span className="ol-block">{matchResult[1]}.</span>
      <span>{parsedContent}</span>
    </p>
  );
};

export default {
  name: "ordered list",
  regexp: ORDERED_LIST_REG,
  renderer,
};
const renderer = (rawStr: string) => {
  return <p>{parsedContent}</p>;
  regexp: PARAGRAPH_REG,
import { escape } from "lodash-es";
import { matcher } from "../matcher";

export const PLAIN_LINK_REG = /(https?:\/\/[^ ]+)/;

const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, PLAIN_LINK_REG);
  if (!matchResult) {
    return rawStr;
  }

  return (
    <a className="link" target="_blank" rel="noreferrer" href={escape(matchResult[1])}>
      {escape(matchResult[1])}
    </a>
  );
};

export default {
  name: "plain link",
  regexp: PLAIN_LINK_REG,
  renderer,
};
import { matcher } from "../matcher";
  const matchResult = matcher(rawStr, PLAIN_TEXT_REG);
  regexp: PLAIN_TEXT_REG,
import { escape } from "lodash";
import { matcher } from "../matcher";

export const STRIKETHROUGH_REG = /~~(.+?)~~/;

const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, STRIKETHROUGH_REG);
  if (!matchResult) {
    return rawStr;
  }

  return <del>{escape(matchResult[1])}</del>;
};

export default {
  name: "Strikethrough",
  regexp: STRIKETHROUGH_REG,
  renderer,
};
import { escape } from "lodash-es";
import { matcher } from "../matcher";

export const TAG_REG = /#([^\s#]+)/;

const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, TAG_REG);
  if (!matchResult) {
    return rawStr;
  }

  return <span className="tag-span">#{escape(matchResult[1])}</span>;
};

export default {
  name: "tag",
  regexp: TAG_REG,
  renderer,
};
import { inlineElementParserList } from ".";
import { marked } from "..";
import { matcher } from "../matcher";

export const TODO_LIST_REG = /^- \[ \] ([^\n]+)/;

const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, TODO_LIST_REG);
  if (!matchResult) {
    return rawStr;
  }

  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
  return (
    <p className="li-container">
      <span className="todo-block todo" data-value="TODO"></span>
      <span>{parsedContent}</span>
    </p>
  );
};

export default {
  name: "todo list",
  regexp: TODO_LIST_REG,
  renderer,
};
import { inlineElementParserList } from ".";
import { marked } from "..";
import { matcher } from "../matcher";

export const UNORDERED_LIST_REG = /^[*-] ([^\n]+)/;

const renderer = (rawStr: string) => {
  const matchResult = matcher(rawStr, UNORDERED_LIST_REG);
  if (!matchResult) {
    return rawStr;
  }

  const parsedContent = marked(matchResult[1], [], inlineElementParserList);
  return (
    <p className="li-container">
      <span className="ul-block">•</span>
      <span>${parsedContent}</span>
    </p>
  );
};

export default {
  name: "unordered list",
  regexp: UNORDERED_LIST_REG,
  renderer,
};