mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
feat: simple markdown parser (#252)
* feat: simple markdown parser * chore: rename test file name * feat: add plain text link parser * chore: update style
This commit is contained in:
@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import "dayjs/locale/zh";
|
import "dayjs/locale/zh";
|
||||||
import { UNKNOWN_ID } from "../helpers/consts";
|
import { UNKNOWN_ID } from "../helpers/consts";
|
||||||
import { DONE_BLOCK_REG, TODO_BLOCK_REG } from "../helpers/marked";
|
|
||||||
import { editorStateService, locationService, memoService, userService } from "../services";
|
import { editorStateService, locationService, memoService, userService } from "../services";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import toastHelper from "./Toast";
|
import toastHelper from "./Toast";
|
||||||
@ -134,7 +133,7 @@ const Memo: React.FC<Props> = (props: Props) => {
|
|||||||
for (const element of todoElementList) {
|
for (const element of todoElementList) {
|
||||||
if (element === targetEl) {
|
if (element === targetEl) {
|
||||||
const index = indexOf(todoElementList, element);
|
const index = indexOf(todoElementList, element);
|
||||||
const tempList = memo.content.split(status === "DONE" ? DONE_BLOCK_REG : TODO_BLOCK_REG);
|
const tempList = memo.content.split(status === "DONE" ? /- \[x\] / : /- \[ \] /);
|
||||||
let finalContent = "";
|
let finalContent = "";
|
||||||
|
|
||||||
for (let i = 0; i < tempList.length; i++) {
|
for (let i = 0; i < tempList.length; i++) {
|
||||||
|
@ -4,7 +4,9 @@ import { editorStateService, memoService, userService } from "../services";
|
|||||||
import { useAppSelector } from "../store";
|
import { useAppSelector } from "../store";
|
||||||
import { UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
|
import { UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
|
||||||
import * as utils from "../helpers/utils";
|
import * as utils from "../helpers/utils";
|
||||||
import { formatMemoContent, MEMO_LINK_REG, parseHtmlToRawText } from "../helpers/marked";
|
import { parseHTMLToRawText } from "../helpers/utils";
|
||||||
|
import { marked } from "../labs/marked";
|
||||||
|
import { MARK_REG } from "../labs/marked/parser";
|
||||||
import toastHelper from "./Toast";
|
import toastHelper from "./Toast";
|
||||||
import { generateDialog } from "./Dialog";
|
import { generateDialog } from "./Dialog";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
@ -43,7 +45,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
|||||||
const fetchLinkedMemos = async () => {
|
const fetchLinkedMemos = async () => {
|
||||||
try {
|
try {
|
||||||
const linkMemos: LinkedMemo[] = [];
|
const linkMemos: LinkedMemo[] = [];
|
||||||
const matchedArr = [...memo.content.matchAll(MEMO_LINK_REG)];
|
const matchedArr = [...memo.content.matchAll(MARK_REG)];
|
||||||
for (const matchRes of matchedArr) {
|
for (const matchRes of matchedArr) {
|
||||||
if (matchRes && matchRes.length === 3) {
|
if (matchRes && matchRes.length === 3) {
|
||||||
const id = Number(matchRes[2]);
|
const id = Number(matchRes[2]);
|
||||||
@ -208,7 +210,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
|||||||
<div className="linked-memos-wrapper">
|
<div className="linked-memos-wrapper">
|
||||||
<p className="normal-text">{linkMemos.length} related MEMO</p>
|
<p className="normal-text">{linkMemos.length} related MEMO</p>
|
||||||
{linkMemos.map((memo, index) => {
|
{linkMemos.map((memo, index) => {
|
||||||
const rawtext = parseHtmlToRawText(formatMemoContent(memo.content)).replaceAll("\n", " ");
|
const rawtext = parseHTMLToRawText(marked(memo.content)).replaceAll("\n", " ");
|
||||||
return (
|
return (
|
||||||
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
|
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
|
||||||
<span className="time-text">{memo.dateStr} </span>
|
<span className="time-text">{memo.dateStr} </span>
|
||||||
@ -222,7 +224,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
|||||||
<div className="linked-memos-wrapper">
|
<div className="linked-memos-wrapper">
|
||||||
<p className="normal-text">{linkedMemos.length} linked MEMO</p>
|
<p className="normal-text">{linkedMemos.length} linked MEMO</p>
|
||||||
{linkedMemos.map((memo, index) => {
|
{linkedMemos.map((memo, index) => {
|
||||||
const rawtext = parseHtmlToRawText(formatMemoContent(memo.content)).replaceAll("\n", " ");
|
const rawtext = parseHTMLToRawText(marked(memo.content)).replaceAll("\n", " ");
|
||||||
return (
|
return (
|
||||||
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
|
<div className="linked-memo-container" key={`${index}-${memo.id}`} onClick={() => handleLinkedMemoClick(memo)}>
|
||||||
<span className="time-text">{memo.dateStr} </span>
|
<span className="time-text">{memo.dateStr} </span>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { formatMemoContent } from "../helpers/marked";
|
import { marked } from "../labs/marked";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import "../less/memo-content.less";
|
import "../less/memo-content.less";
|
||||||
|
|
||||||
@ -79,7 +79,7 @@ const MemoContent: React.FC<Props> = (props: Props) => {
|
|||||||
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
|
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
|
||||||
onClick={handleMemoContentClick}
|
onClick={handleMemoContentClick}
|
||||||
onDoubleClick={handleMemoContentDoubleClick}
|
onDoubleClick={handleMemoContentDoubleClick}
|
||||||
dangerouslySetInnerHTML={{ __html: formatMemoContent(content) }}
|
dangerouslySetInnerHTML={{ __html: marked(content) }}
|
||||||
></div>
|
></div>
|
||||||
{state.expandButtonStatus !== -1 && (
|
{state.expandButtonStatus !== -1 && (
|
||||||
<div className="expand-btn-container">
|
<div className="expand-btn-container">
|
||||||
|
@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { memoService, shortcutService } from "../services";
|
import { memoService, shortcutService } from "../services";
|
||||||
import { useAppSelector } from "../store";
|
import { useAppSelector } from "../store";
|
||||||
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/marked";
|
import { TAG_REG, LINK_REG } from "../labs/marked/parser";
|
||||||
import * as utils from "../helpers/utils";
|
import * as utils from "../helpers/utils";
|
||||||
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
|
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
|
||||||
import toastHelper from "./Toast";
|
import toastHelper from "./Toast";
|
||||||
@ -57,11 +57,7 @@ const MemoList = () => {
|
|||||||
if (memoType) {
|
if (memoType) {
|
||||||
if (memoType === "NOT_TAGGED" && memo.content.match(TAG_REG) !== null) {
|
if (memoType === "NOT_TAGGED" && memo.content.match(TAG_REG) !== null) {
|
||||||
shouldShow = false;
|
shouldShow = false;
|
||||||
} else if (memoType === "LINKED" && memo.content.match(LINK_URL_REG) === null) {
|
} else if (memoType === "LINKED" && memo.content.match(LINK_REG) === null) {
|
||||||
shouldShow = false;
|
|
||||||
} else if (memoType === "IMAGED" && memo.content.match(IMAGE_URL_REG) === null) {
|
|
||||||
shouldShow = false;
|
|
||||||
} else if (memoType === "CONNECTED" && memo.content.match(MEMO_LINK_REG) === null) {
|
|
||||||
shouldShow = false;
|
shouldShow = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG } from "./marked";
|
import { TAG_REG, LINK_REG } from "../labs/marked/parser";
|
||||||
|
|
||||||
export const relationConsts = [
|
export const relationConsts = [
|
||||||
{ text: "And", value: "AND" },
|
{ text: "And", value: "AND" },
|
||||||
@ -34,10 +34,6 @@ export const filterConsts = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
values: [
|
values: [
|
||||||
{
|
|
||||||
text: "Connected",
|
|
||||||
value: "CONNECTED",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: "No tags",
|
text: "No tags",
|
||||||
value: "NOT_TAGGED",
|
value: "NOT_TAGGED",
|
||||||
@ -46,10 +42,6 @@ export const filterConsts = {
|
|||||||
text: "Has links",
|
text: "Has links",
|
||||||
value: "LINKED",
|
value: "LINKED",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: "Has images",
|
|
||||||
value: "IMAGED",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
TEXT: {
|
TEXT: {
|
||||||
@ -142,11 +134,7 @@ export const checkShouldShowMemo = (memo: Memo, filter: Filter) => {
|
|||||||
let matched = false;
|
let matched = false;
|
||||||
if (value === "NOT_TAGGED" && memo.content.match(TAG_REG) === null) {
|
if (value === "NOT_TAGGED" && memo.content.match(TAG_REG) === null) {
|
||||||
matched = true;
|
matched = true;
|
||||||
} else if (value === "LINKED" && memo.content.match(LINK_URL_REG) !== null) {
|
} else if (value === "LINKED" && memo.content.match(LINK_REG) !== null) {
|
||||||
matched = true;
|
|
||||||
} else if (value === "IMAGED" && memo.content.match(IMAGE_URL_REG) !== null) {
|
|
||||||
matched = true;
|
|
||||||
} else if (value === "CONNECTED" && memo.content.match(MEMO_LINK_REG) !== null) {
|
|
||||||
matched = true;
|
matched = true;
|
||||||
}
|
}
|
||||||
if (operator === "IS_NOT") {
|
if (operator === "IS_NOT") {
|
||||||
|
@ -1,50 +0,0 @@
|
|||||||
import { escape } from "lodash-es";
|
|
||||||
|
|
||||||
const CODE_BLOCK_REG = /```([\s\S]*?)```\n?/g;
|
|
||||||
const BOLD_TEXT_REG = /\*\*(.+?)\*\*/g;
|
|
||||||
const EM_TEXT_REG = /\*(.+?)\*/g;
|
|
||||||
const DOT_LI_REG = /[*-] /g;
|
|
||||||
const NUM_LI_REG = /(\d+)\. /g;
|
|
||||||
export const TODO_BLOCK_REG = /- \[ \] /g;
|
|
||||||
export const DONE_BLOCK_REG = /- \[x\] /g;
|
|
||||||
// tag regex
|
|
||||||
export const TAG_REG = /#([^\s#]+?) /g;
|
|
||||||
// markdown image regex
|
|
||||||
export const IMAGE_URL_REG = /!\[.*?\]\((.+?)\)\n?/g;
|
|
||||||
// markdown link regex
|
|
||||||
export const LINK_URL_REG = /\[(.*?)\]\((.+?)\)/g;
|
|
||||||
// linked memo regex
|
|
||||||
export const MEMO_LINK_REG = /@\[(.+?)\]\((.+?)\)/g;
|
|
||||||
|
|
||||||
const parseMarkedToHtml = (markedStr: string): string => {
|
|
||||||
const htmlText = markedStr
|
|
||||||
.replace(CODE_BLOCK_REG, "<pre lang=''>$1</pre>")
|
|
||||||
.replace(TODO_BLOCK_REG, "<span class='todo-block todo' data-value='TODO'></span>")
|
|
||||||
.replace(DONE_BLOCK_REG, "<span class='todo-block done' data-value='DONE'>✓</span>")
|
|
||||||
.replace(DOT_LI_REG, "<span class='counter-block'>•</span>")
|
|
||||||
.replace(NUM_LI_REG, "<span class='counter-block'>$1.</span>")
|
|
||||||
.replace(BOLD_TEXT_REG, "<strong>$1</strong>")
|
|
||||||
.replace(EM_TEXT_REG, "<em>$1</em>");
|
|
||||||
return htmlText;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseHtmlToRawText = (htmlStr: string): string => {
|
|
||||||
const tempEl = document.createElement("div");
|
|
||||||
tempEl.className = "memo-content-text";
|
|
||||||
tempEl.innerHTML = htmlStr;
|
|
||||||
const text = tempEl.innerText;
|
|
||||||
return text;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatMemoContent = (content: string) => {
|
|
||||||
const tempElement = document.createElement("div");
|
|
||||||
tempElement.innerHTML = parseMarkedToHtml(escape(content));
|
|
||||||
|
|
||||||
return tempElement.innerHTML
|
|
||||||
.replace(IMAGE_URL_REG, "<img class='img' src='$1' />")
|
|
||||||
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>")
|
|
||||||
.replace(LINK_URL_REG, "<a class='link' target='_blank' rel='noreferrer' href='$2'>$1</a>")
|
|
||||||
.replace(TAG_REG, "<span class='tag-span'>#$1</span> ");
|
|
||||||
};
|
|
||||||
|
|
||||||
export { formatMemoContent, parseHtmlToRawText };
|
|
@ -80,102 +80,6 @@ export function getDateTimeString(t: Date | number | string): string {
|
|||||||
return `${year}/${monthStr}/${dateStr} ${hoursStr}:${minsStr}:${secsStr}`;
|
return `${year}/${monthStr}/${dateStr} ${hoursStr}:${minsStr}:${secsStr}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dedupe<T>(data: T[]): T[] {
|
|
||||||
return Array.from(new Set(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dedupeObjectWithId<T extends { id: string | number }>(data: T[]): T[] {
|
|
||||||
const idSet = new Set<string | number>();
|
|
||||||
const result = [];
|
|
||||||
|
|
||||||
for (const d of data) {
|
|
||||||
if (!idSet.has(d.id)) {
|
|
||||||
idSet.add(d.id);
|
|
||||||
result.push(d);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function debounce(fn: FunctionType, delay: number) {
|
|
||||||
let timer: number | null = null;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(fn, delay);
|
|
||||||
} else {
|
|
||||||
timer = setTimeout(fn, delay);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function throttle(fn: FunctionType, delay: number) {
|
|
||||||
let valid = true;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (!valid) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
valid = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
fn();
|
|
||||||
valid = true;
|
|
||||||
}, delay);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterObjectNullKeys(object: KVObject): KVObject {
|
|
||||||
if (!object) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalObject: KVObject = {};
|
|
||||||
const keys = Object.keys(object).sort();
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
const val = object[key];
|
|
||||||
if (typeof val === "object") {
|
|
||||||
const temp = filterObjectNullKeys(JSON.parse(JSON.stringify(val)));
|
|
||||||
if (temp && Object.keys(temp).length > 0) {
|
|
||||||
finalObject[key] = temp;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (val) {
|
|
||||||
finalObject[key] = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getImageSize(src: string): Promise<{ width: number; height: number }> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const imgEl = new Image();
|
|
||||||
|
|
||||||
imgEl.onload = () => {
|
|
||||||
const { width, height } = imgEl;
|
|
||||||
|
|
||||||
if (width > 0 && height > 0) {
|
|
||||||
resolve({ width, height });
|
|
||||||
} else {
|
|
||||||
resolve({ width: 0, height: 0 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
imgEl.onerror = () => {
|
|
||||||
resolve({ width: 0, height: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
imgEl.className = "hidden";
|
|
||||||
imgEl.src = src;
|
|
||||||
document.body.appendChild(imgEl);
|
|
||||||
imgEl.remove();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getElementBounding = (element: HTMLElement, relativeEl?: HTMLElement) => {
|
export const getElementBounding = (element: HTMLElement, relativeEl?: HTMLElement) => {
|
||||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;
|
||||||
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft;
|
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft;
|
||||||
@ -224,3 +128,11 @@ export const getElementBounding = (element: HTMLElement, relativeEl?: HTMLElemen
|
|||||||
left: elementRect.left + scrollLeft,
|
left: elementRect.left + scrollLeft,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parseHTMLToRawText = (htmlStr: string): string => {
|
||||||
|
const tempEl = document.createElement("div");
|
||||||
|
tempEl.className = "memo-content-text";
|
||||||
|
tempEl.innerHTML = htmlStr;
|
||||||
|
const text = tempEl.innerText;
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
18
web/src/labs/marked/index.ts
Normal file
18
web/src/labs/marked/index.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { parserList } from "./parser";
|
||||||
|
|
||||||
|
export const marked = (markdownStr: string, parsers = parserList) => {
|
||||||
|
for (const parser of parsers) {
|
||||||
|
const startIndex = markdownStr.search(parser.regex);
|
||||||
|
const matchedLength = parser.match(markdownStr);
|
||||||
|
|
||||||
|
if (startIndex > -1 && matchedLength > 0) {
|
||||||
|
const prefixStr = markdownStr.slice(0, startIndex);
|
||||||
|
const matchedStr = markdownStr.slice(startIndex, startIndex + matchedLength);
|
||||||
|
const suffixStr = markdownStr.slice(startIndex + matchedLength);
|
||||||
|
markdownStr = marked(prefixStr, parsers) + parser.renderer(matchedStr) + marked(suffixStr, parsers);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdownStr;
|
||||||
|
};
|
89
web/src/labs/marked/marked.test.ts
Normal file
89
web/src/labs/marked/marked.test.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, test } from "@jest/globals";
|
||||||
|
import { marked } from ".";
|
||||||
|
|
||||||
|
describe("test marked parser", () => {
|
||||||
|
test("parse code block", () => {
|
||||||
|
const tests = [
|
||||||
|
{
|
||||||
|
markdown: `\`\`\`
|
||||||
|
hello world!
|
||||||
|
\`\`\``,
|
||||||
|
want: `<pre lang=''>
|
||||||
|
hello world!
|
||||||
|
</pre>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
markdown: `test code block
|
||||||
|
|
||||||
|
\`\`\`js
|
||||||
|
console.log("hello world!")
|
||||||
|
\`\`\``,
|
||||||
|
want: `<p>test code block</p>
|
||||||
|
<p></p>
|
||||||
|
<pre lang='js'>
|
||||||
|
console.log("hello world!")
|
||||||
|
</pre>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const t of tests) {
|
||||||
|
expect(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><span class='todo-block todo' data-value='TODO'></span>finish my homework</p>
|
||||||
|
<p><span class='todo-block done' data-value='DONE'>✓</span>yahaha</p>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const t of tests) {
|
||||||
|
expect(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><span class='ul-block'>•</span>list 123</p>
|
||||||
|
<p><span class='ol-block'>1.</span>123123</p>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const t of tests) {
|
||||||
|
expect(marked(t.markdown)).toBe(t.want);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("parse inline element", () => {
|
||||||
|
const tests = [
|
||||||
|
{
|
||||||
|
markdown: `Link: [baidu](https://baidu.com)`,
|
||||||
|
want: `<p>Link: <a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'>baidu</a></p>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const t of tests) {
|
||||||
|
expect(marked(t.markdown)).toBe(t.want);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("parse plain link", () => {
|
||||||
|
const tests = [
|
||||||
|
{
|
||||||
|
markdown: `Link:https://baidu.com`,
|
||||||
|
want: `<p>Link:<a class='link' target='_blank' rel='noreferrer' href='https://baidu.com'>https://baidu.com</a></p>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const t of tests) {
|
||||||
|
expect(marked(t.markdown)).toBe(t.want);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
23
web/src/labs/marked/parser/Bold.ts
Normal file
23
web/src/labs/marked/parser/Bold.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const BOLD_REG = /\*\*([\S ]+?)\*\*/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(BOLD_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const parsedStr = rawStr.replace(BOLD_REG, "<strong>$1</strong>");
|
||||||
|
return parsedStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "bold",
|
||||||
|
regex: BOLD_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
23
web/src/labs/marked/parser/CodeBlock.ts
Normal file
23
web/src/labs/marked/parser/CodeBlock.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const CODE_BLOCK_REG = /^```(\S*?)\s([\s\S]*?)```(\n?)/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(CODE_BLOCK_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const parsedStr = rawStr.replace(CODE_BLOCK_REG, "<pre lang='$1'>\n$2</pre>$3");
|
||||||
|
return parsedStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "code block",
|
||||||
|
regex: CODE_BLOCK_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
31
web/src/labs/marked/parser/DoneList.ts
Normal file
31
web/src/labs/marked/parser/DoneList.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { inlineElementParserList } from ".";
|
||||||
|
import { marked } from "..";
|
||||||
|
|
||||||
|
export const DONE_LIST_REG = /^- \[x\] ([\S ]+)(\n?)/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(DONE_LIST_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const matchResult = rawStr.match(DONE_LIST_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return rawStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContent = marked(matchResult[1], inlineElementParserList);
|
||||||
|
return `<p><span class='todo-block done' data-value='DONE'>✓</span>${parsedContent}</p>${matchResult[2]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "done list",
|
||||||
|
regex: DONE_LIST_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
23
web/src/labs/marked/parser/Emphasis.ts
Normal file
23
web/src/labs/marked/parser/Emphasis.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const EMPHASIS_REG = /\*([\S ]+?)\*/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(EMPHASIS_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const parsedStr = rawStr.replace(EMPHASIS_REG, "<em>$1</em>");
|
||||||
|
return parsedStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "emphasis",
|
||||||
|
regex: EMPHASIS_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
23
web/src/labs/marked/parser/Image.ts
Normal file
23
web/src/labs/marked/parser/Image.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const IMAGE_REG = /!\[.*?\]\((.+?)\)/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(IMAGE_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const parsedStr = rawStr.replace(IMAGE_REG, "<img class='img' src='$1' />");
|
||||||
|
return parsedStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "image",
|
||||||
|
regex: IMAGE_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
23
web/src/labs/marked/parser/Link.ts
Normal file
23
web/src/labs/marked/parser/Link.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const LINK_REG = /\[(.*?)\]\((.+?)\)/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(LINK_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const parsedStr = rawStr.replace(LINK_REG, "<a class='link' target='_blank' rel='noreferrer' href='$2'>$1</a>");
|
||||||
|
return parsedStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "link",
|
||||||
|
regex: LINK_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
23
web/src/labs/marked/parser/Mark.ts
Normal file
23
web/src/labs/marked/parser/Mark.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const MARK_REG = /@\[([\S ]+?)\]\((\S+?)\)/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(MARK_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const parsedStr = rawStr.replace(MARK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>");
|
||||||
|
return parsedStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "mark",
|
||||||
|
regex: MARK_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
31
web/src/labs/marked/parser/OrderedList.ts
Normal file
31
web/src/labs/marked/parser/OrderedList.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { inlineElementParserList } from ".";
|
||||||
|
import { marked } from "..";
|
||||||
|
|
||||||
|
export const ORDERED_LIST_REG = /^(\d+)\. ([\S ]+)(\n?)/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(ORDERED_LIST_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const matchResult = rawStr.match(ORDERED_LIST_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return rawStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContent = marked(matchResult[2], inlineElementParserList);
|
||||||
|
return `<p><span class='ol-block'>${matchResult[1]}.</span>${parsedContent}</p>${matchResult[3]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "ordered list",
|
||||||
|
regex: ORDERED_LIST_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
31
web/src/labs/marked/parser/Paragraph.ts
Normal file
31
web/src/labs/marked/parser/Paragraph.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { inlineElementParserList } from ".";
|
||||||
|
import { marked } from "..";
|
||||||
|
|
||||||
|
export const PARAGRAPH_REG = /^([\S ]*)(\n?)/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(PARAGRAPH_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const matchResult = rawStr.match(PARAGRAPH_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return rawStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContent = marked(matchResult[1], inlineElementParserList);
|
||||||
|
return `<p>${parsedContent}</p>${matchResult[2]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "ordered list",
|
||||||
|
regex: PARAGRAPH_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
23
web/src/labs/marked/parser/PlainLink.ts
Normal file
23
web/src/labs/marked/parser/PlainLink.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const PLAIN_LINK_REG = /(https?:\/\/[^ ]+)/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(PLAIN_LINK_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const parsedStr = rawStr.replace(PLAIN_LINK_REG, "<a class='link' target='_blank' rel='noreferrer' href='$1'>$1</a>");
|
||||||
|
return parsedStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "plain link",
|
||||||
|
regex: PLAIN_LINK_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
23
web/src/labs/marked/parser/Tag.ts
Normal file
23
web/src/labs/marked/parser/Tag.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const TAG_REG = /#([^\s#]+?) /;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(TAG_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const parsedStr = rawStr.replace(TAG_REG, "<span class='tag-span'>#$1</span> ");
|
||||||
|
return parsedStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "tag",
|
||||||
|
regex: TAG_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
31
web/src/labs/marked/parser/TodoList.ts
Normal file
31
web/src/labs/marked/parser/TodoList.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { inlineElementParserList } from ".";
|
||||||
|
import { marked } from "..";
|
||||||
|
|
||||||
|
export const TODO_LIST_REG = /^- \[ \] ([\S ]+)(\n?)/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(TODO_LIST_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const matchResult = rawStr.match(TODO_LIST_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return rawStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContent = marked(matchResult[1], inlineElementParserList);
|
||||||
|
return `<p><span class='todo-block todo' data-value='TODO'></span>${parsedContent}</p>${matchResult[2]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "todo list",
|
||||||
|
regex: TODO_LIST_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
31
web/src/labs/marked/parser/UnorderedList.ts
Normal file
31
web/src/labs/marked/parser/UnorderedList.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { inlineElementParserList } from ".";
|
||||||
|
import { marked } from "..";
|
||||||
|
|
||||||
|
export const UNORDERED_LIST_REG = /^[*-] ([\S ]+)(\n?)/;
|
||||||
|
|
||||||
|
const match = (rawStr: string): number => {
|
||||||
|
const matchResult = rawStr.match(UNORDERED_LIST_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStr = matchResult[0];
|
||||||
|
return matchStr.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = (rawStr: string): string => {
|
||||||
|
const matchResult = rawStr.match(UNORDERED_LIST_REG);
|
||||||
|
if (!matchResult) {
|
||||||
|
return rawStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContent = marked(matchResult[1], inlineElementParserList);
|
||||||
|
return `<p><span class='ul-block'>•</span>${parsedContent}</p>${matchResult[2]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "unordered list",
|
||||||
|
regex: UNORDERED_LIST_REG,
|
||||||
|
match,
|
||||||
|
renderer,
|
||||||
|
};
|
31
web/src/labs/marked/parser/index.ts
Normal file
31
web/src/labs/marked/parser/index.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import CodeBlock from "./CodeBlock";
|
||||||
|
import TodoList from "./TodoList";
|
||||||
|
import DoneList from "./DoneList";
|
||||||
|
import OrderedList from "./OrderedList";
|
||||||
|
import UnorderedList from "./UnorderedList";
|
||||||
|
import Paragraph from "./Paragraph";
|
||||||
|
import Tag from "./Tag";
|
||||||
|
import Image from "./Image";
|
||||||
|
import Link from "./Link";
|
||||||
|
import Mark from "./Mark";
|
||||||
|
import Bold from "./Bold";
|
||||||
|
import Emphasis from "./Emphasis";
|
||||||
|
import PlainLink from "./PlainLink";
|
||||||
|
|
||||||
|
export { CODE_BLOCK_REG } from "./CodeBlock";
|
||||||
|
export { TODO_LIST_REG } from "./TodoList";
|
||||||
|
export { DONE_LIST_REG } from "./DoneList";
|
||||||
|
export { ORDERED_LIST_REG } from "./OrderedList";
|
||||||
|
export { UNORDERED_LIST_REG } from "./UnorderedList";
|
||||||
|
export { PARAGRAPH_REG } from "./Paragraph";
|
||||||
|
export { TAG_REG } from "./Tag";
|
||||||
|
export { IMAGE_REG } from "./Image";
|
||||||
|
export { LINK_REG } from "./Link";
|
||||||
|
export { MARK_REG } from "./Mark";
|
||||||
|
export { BOLD_REG } from "./Bold";
|
||||||
|
export { EMPHASIS_REG } from "./Emphasis";
|
||||||
|
|
||||||
|
// The order determines the order of execution.
|
||||||
|
export const blockElementParserList = [CodeBlock, TodoList, DoneList, OrderedList, UnorderedList, Paragraph];
|
||||||
|
export const inlineElementParserList = [Image, Mark, Link, Bold, Emphasis, Tag, PlainLink];
|
||||||
|
export const parserList = [...blockElementParserList, ...inlineElementParserList];
|
@ -20,8 +20,8 @@
|
|||||||
> .memo-container {
|
> .memo-container {
|
||||||
@apply w-full overflow-x-hidden flex flex-col justify-start items-start;
|
@apply w-full overflow-x-hidden flex flex-col justify-start items-start;
|
||||||
|
|
||||||
> .memo-content-container {
|
.memo-content-text {
|
||||||
@apply flex flex-col justify-start items-start w-full overflow-x-hidden p-0 text-base;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
@apply w-full flex flex-col justify-start items-start;
|
@apply w-full flex flex-col justify-start items-start;
|
||||||
|
|
||||||
> .memo-content-text {
|
> .memo-content-text {
|
||||||
@apply w-full whitespace-pre-wrap break-words text-base leading-7;
|
@apply w-full break-words text-base leading-7;
|
||||||
|
|
||||||
&.expanded {
|
&.expanded {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@ -14,7 +14,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
> p {
|
> p {
|
||||||
@apply inline-block w-full h-auto mb-1 last:mb-0 text-base leading-7 whitespace-pre-wrap break-words;
|
@apply w-full h-auto mb-1 last:mb-0 text-base leading-6 whitespace-pre-wrap break-words;
|
||||||
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.img {
|
.img {
|
||||||
@ -30,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
@apply inline-block text-blue-600 cursor-pointer underline break-all hover:opacity-80;
|
@apply text-blue-600 cursor-pointer underline break-all hover:opacity-80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.counter-block,
|
.counter-block,
|
||||||
|
Reference in New Issue
Block a user