diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index 20c56ed3..ad314add 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -26,48 +26,75 @@ import { testMatrixTo, } from '../../plugins/matrix-to'; import { tryDecodeURIComponent } from '../../utils/dom'; +import { + escapeMarkdownInlineSequences, + escapeMarkdownBlockSequences, +} from '../../plugins/markdown'; -const markNodeToType: Record = { - b: MarkType.Bold, - strong: MarkType.Bold, - i: MarkType.Italic, - em: MarkType.Italic, - u: MarkType.Underline, - s: MarkType.StrikeThrough, - del: MarkType.StrikeThrough, - code: MarkType.Code, - span: MarkType.Spoiler, -}; +type ProcessTextCallback = (text: string) => string; -const elementToTextMark = (node: Element): MarkType | undefined => { - const markType = markNodeToType[node.name]; - if (!markType) return undefined; - - if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) { - return undefined; - } - if ( - markType === MarkType.Code && - node.parent && - 'name' in node.parent && - node.parent.name === 'pre' - ) { - return undefined; - } - return markType; -}; - -const parseNodeText = (node: ChildNode): string => { +const getText = (node: ChildNode): string => { if (isText(node)) { return node.data; } if (isTag(node)) { - return node.children.map((child) => parseNodeText(child)).join(''); + return node.children.map((child) => getText(child)).join(''); } return ''; }; -const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => { +const getInlineNodeMarkType = (node: Element): MarkType | undefined => { + if (node.name === 'b' || node.name === 'strong') { + return MarkType.Bold; + } + + if (node.name === 'i' || node.name === 'em') { + return MarkType.Italic; + } + + if (node.name === 'u') { + return MarkType.Underline; + } + + if (node.name === 's' || node.name === 'del') { + return MarkType.StrikeThrough; + } + + if (node.name === 'code') { + if (node.parent && 'name' in node.parent && node.parent.name === 'pre') { + return undefined; // Don't apply `Code` mark inside a
 tag
+    }
+    return MarkType.Code;
+  }
+
+  if (node.name === 'span' && node.attribs['data-mx-spoiler'] !== undefined) {
+    return MarkType.Spoiler;
+  }
+
+  return undefined;
+};
+
+const getInlineMarkElement = (
+  markType: MarkType,
+  node: Element,
+  getChild: (child: ChildNode) => InlineElement[]
+): InlineElement[] => {
+  const children = node.children.flatMap(getChild);
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
+    children.unshift({ text: mdSequence });
+    children.push({ text: mdSequence });
+    return children;
+  }
+  children.forEach((child) => {
+    if (Text.isText(child)) {
+      child[markType] = true;
+    }
+  });
+  return children;
+};
+
+const getInlineNonMarkElement = (node: Element): MentionElement | EmoticonElement | undefined => {
   if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
     const { src, alt } = node.attribs;
     if (!src) return undefined;
@@ -79,13 +106,13 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
     if (testMatrixTo(href)) {
       const userMention = parseMatrixToUser(href);
       if (userMention) {
-        return createMentionElement(userMention, parseNodeText(node) || userMention, false);
+        return createMentionElement(userMention, getText(node) || userMention, false);
       }
       const roomMention = parseMatrixToRoom(href);
       if (roomMention) {
         return createMentionElement(
           roomMention.roomIdOrAlias,
-          parseNodeText(node) || roomMention.roomIdOrAlias,
+          getText(node) || roomMention.roomIdOrAlias,
           false,
           undefined,
           roomMention.viaServers
@@ -95,7 +122,7 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
       if (eventMention) {
         return createMentionElement(
           eventMention.roomIdOrAlias,
-          parseNodeText(node) || eventMention.roomIdOrAlias,
+          getText(node) || eventMention.roomIdOrAlias,
           false,
           eventMention.eventId,
           eventMention.viaServers
@@ -106,44 +133,40 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
   return undefined;
 };
 
-const parseInlineNodes = (node: ChildNode): InlineElement[] => {
+const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): InlineElement[] => {
   if (isText(node)) {
-    return [{ text: node.data }];
+    return [{ text: processText(node.data) }];
   }
+
   if (isTag(node)) {
-    const markType = elementToTextMark(node);
+    const markType = getInlineNodeMarkType(node);
     if (markType) {
-      const children = node.children.flatMap(parseInlineNodes);
-      if (node.attribs['data-md'] !== undefined) {
-        children.unshift({ text: node.attribs['data-md'] });
-        children.push({ text: node.attribs['data-md'] });
-      } else {
-        children.forEach((child) => {
-          if (Text.isText(child)) {
-            child[markType] = true;
-          }
-        });
-      }
-      return children;
+      return getInlineMarkElement(markType, node, (child) => {
+        if (markType === MarkType.Code) return [{ text: getText(child) }];
+        return getInlineElement(child, processText);
+      });
     }
 
-    const inlineNode = elementToInlineNode(node);
+    const inlineNode = getInlineNonMarkElement(node);
     if (inlineNode) return [inlineNode];
 
     if (node.name === 'a') {
-      const children = node.childNodes.flatMap(parseInlineNodes);
+      const children = node.childNodes.flatMap((child) => getInlineElement(child, processText));
       children.unshift({ text: '[' });
       children.push({ text: `](${node.attribs.href})` });
       return children;
     }
 
-    return node.childNodes.flatMap(parseInlineNodes);
+    return node.childNodes.flatMap((child) => getInlineElement(child, processText));
   }
 
   return [];
 };
 
-const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => {
+const parseBlockquoteNode = (
+  node: Element,
+  processText: ProcessTextCallback
+): BlockQuoteElement[] | ParagraphElement[] => {
   const quoteLines: Array = [];
   let lineHolder: InlineElement[] = [];
 
@@ -156,7 +179,7 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
 
   node.children.forEach((child) => {
     if (isText(child)) {
-      lineHolder.push({ text: child.data });
+      lineHolder.push({ text: processText(child.data) });
       return;
     }
     if (isTag(child)) {
@@ -168,19 +191,20 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
 
       if (child.name === 'p') {
         appendLine();
-        quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
+        quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
         return;
       }
 
-      parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+      lineHolder.push(...getInlineElement(child, processText));
     }
   });
   appendLine();
 
-  if (node.attribs['data-md'] !== undefined) {
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
     return quoteLines.map((lineChildren) => ({
       type: BlockType.Paragraph,
-      children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren],
+      children: [{ text: `${mdSequence} ` }, ...lineChildren],
     }));
   }
 
@@ -195,22 +219,19 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
   ];
 };
 const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => {
-  const codeLines = parseNodeText(node).trim().split('\n');
+  const codeLines = getText(node).trim().split('\n');
 
-  if (node.attribs['data-md'] !== undefined) {
-    const pLines = codeLines.map((lineText) => ({
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
+    const pLines = codeLines.map((text) => ({
       type: BlockType.Paragraph,
-      children: [
-        {
-          text: lineText,
-        },
-      ],
+      children: [{ text }],
     }));
     const childCode = node.children[0];
     const className =
       isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
-    const prefix = { text: `${node.attribs['data-md']}${className.replace('language-', '')}` };
-    const suffix = { text: node.attribs['data-md'] };
+    const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
+    const suffix = { text: mdSequence };
     return [
       { type: BlockType.Paragraph, children: [prefix] },
       ...pLines,
@@ -221,19 +242,16 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
   return [
     {
       type: BlockType.CodeBlock,
-      children: codeLines.map((lineTxt) => ({
+      children: codeLines.map((text) => ({
         type: BlockType.CodeLine,
-        children: [
-          {
-            text: lineTxt,
-          },
-        ],
+        children: [{ text }],
       })),
     },
   ];
 };
 const parseListNode = (
-  node: Element
+  node: Element,
+  processText: ProcessTextCallback
 ): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
   const listLines: Array = [];
   let lineHolder: InlineElement[] = [];
@@ -247,7 +265,7 @@ const parseListNode = (
 
   node.children.forEach((child) => {
     if (isText(child)) {
-      lineHolder.push({ text: child.data });
+      lineHolder.push({ text: processText(child.data) });
       return;
     }
     if (isTag(child)) {
@@ -259,17 +277,18 @@ const parseListNode = (
 
       if (child.name === 'li') {
         appendLine();
-        listLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
+        listLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
         return;
       }
 
-      parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+      lineHolder.push(...getInlineElement(child, processText));
     }
   });
   appendLine();
 
-  if (node.attribs['data-md'] !== undefined) {
-    const prefix = node.attribs['data-md'] || '-';
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
+    const prefix = mdSequence || '-';
     const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
     return listLines.map((lineChildren) => ({
       type: BlockType.Paragraph,
@@ -302,17 +321,21 @@ const parseListNode = (
     },
   ];
 };
-const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
-  const children = node.children.flatMap((child) => parseInlineNodes(child));
+const parseHeadingNode = (
+  node: Element,
+  processText: ProcessTextCallback
+): HeadingElement | ParagraphElement => {
+  const children = node.children.flatMap((child) => getInlineElement(child, processText));
 
   const headingMatch = node.name.match(/^h([123456])$/);
   const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
   const level = parseInt(g1AsLevel, 10);
 
-  if (node.attribs['data-md'] !== undefined) {
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
     return {
       type: BlockType.Paragraph,
-      children: [{ text: `${node.attribs['data-md']} ` }, ...children],
+      children: [{ text: `${mdSequence} ` }, ...children],
     };
   }
 
@@ -323,7 +346,11 @@ const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
   };
 };
 
-export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
+export const domToEditorInput = (
+  domNodes: ChildNode[],
+  processText: ProcessTextCallback,
+  processLineStartText: ProcessTextCallback
+): Descendant[] => {
   const children: Descendant[] = [];
 
   let lineHolder: InlineElement[] = [];
@@ -340,7 +367,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
 
   domNodes.forEach((node) => {
     if (isText(node)) {
-      lineHolder.push({ text: node.data });
+      if (lineHolder.length === 0) {
+        // we are inserting first part of line
+        // it may contain block markdown starting data
+        // that we may need to escape.
+        lineHolder.push({ text: processLineStartText(node.data) });
+        return;
+      }
+      lineHolder.push({ text: processText(node.data) });
       return;
     }
     if (isTag(node)) {
@@ -354,14 +388,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
         appendLine();
         children.push({
           type: BlockType.Paragraph,
-          children: node.children.flatMap((child) => parseInlineNodes(child)),
+          children: node.children.flatMap((child) => getInlineElement(child, processText)),
         });
         return;
       }
 
       if (node.name === 'blockquote') {
         appendLine();
-        children.push(...parseBlockquoteNode(node));
+        children.push(...parseBlockquoteNode(node, processText));
         return;
       }
       if (node.name === 'pre') {
@@ -371,17 +405,17 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
       }
       if (node.name === 'ol' || node.name === 'ul') {
         appendLine();
-        children.push(...parseListNode(node));
+        children.push(...parseListNode(node, processText));
         return;
       }
 
       if (node.name.match(/^h[123456]$/)) {
         appendLine();
-        children.push(parseHeadingNode(node));
+        children.push(parseHeadingNode(node, processText));
         return;
       }
 
-      parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
+      lineHolder.push(...getInlineElement(node, processText));
     }
   });
   appendLine();
@@ -389,21 +423,31 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
   return children;
 };
 
-export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
+export const htmlToEditorInput = (unsafeHtml: string, markdown?: boolean): Descendant[] => {
   const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
 
+  const processText = (partText: string) => {
+    if (!markdown) return partText;
+    return escapeMarkdownInlineSequences(partText);
+  };
+
   const domNodes = parse(sanitizedHtml);
-  const editorNodes = domToEditorInput(domNodes);
+  const editorNodes = domToEditorInput(domNodes, processText, (lineStartText: string) => {
+    if (!markdown) return lineStartText;
+    return escapeMarkdownBlockSequences(lineStartText, processText);
+  });
   return editorNodes;
 };
 
-export const plainToEditorInput = (text: string): Descendant[] => {
+export const plainToEditorInput = (text: string, markdown?: boolean): Descendant[] => {
   const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
     const paragraphNode: ParagraphElement = {
       type: BlockType.Paragraph,
       children: [
         {
-          text: lineText,
+          text: markdown
+            ? escapeMarkdownBlockSequences(lineText, escapeMarkdownInlineSequences)
+            : lineText,
         },
       ],
     };
diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts
index c5ecc6de..256bdbd9 100644
--- a/src/app/components/editor/output.ts
+++ b/src/app/components/editor/output.ts
@@ -3,7 +3,12 @@ import { Descendant, Text } from 'slate';
 import { sanitizeText } from '../../utils/sanitize';
 import { BlockType } from './types';
 import { CustomElement } from './slate';
-import { parseBlockMD, parseInlineMD } from '../../plugins/markdown';
+import {
+  parseBlockMD,
+  parseInlineMD,
+  unescapeMarkdownBlockSequences,
+  unescapeMarkdownInlineSequences,
+} from '../../plugins/markdown';
 import { findAndReplace } from '../../utils/findAndReplace';
 import { sanitizeForRegex } from '../../utils/regex';
 
@@ -19,7 +24,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
     if (node.bold) string = `${string}`;
     if (node.italic) string = `${string}`;
     if (node.underline) string = `${string}`;
-    if (node.strikeThrough) string = `${string}`;
+    if (node.strikeThrough) string = `${string}`;
     if (node.code) string = `${string}`;
     if (node.spoiler) string = `${string}`;
   }
@@ -102,7 +107,8 @@ export const toMatrixCustomHTML = (
         allowBlockMarkdown: false,
       })
         .replace(/$/, '\n')
-        .replace(/^>/, '>');
+        .replace(/^(\\*)>/, '$1>');
+
       markdownLines += line;
       if (index === targetNodes.length - 1) {
         return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
@@ -157,11 +163,14 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
   }
 };
 
-export const toPlainText = (node: Descendant | Descendant[]): string => {
-  if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join('');
-  if (Text.isText(node)) return node.text;
+export const toPlainText = (node: Descendant | Descendant[], isMarkdown: boolean): string => {
+  if (Array.isArray(node)) return node.map((n) => toPlainText(n, isMarkdown)).join('');
+  if (Text.isText(node))
+    return isMarkdown
+      ? unescapeMarkdownBlockSequences(node.text, unescapeMarkdownInlineSequences)
+      : node.text;
 
-  const children = node.children.map((n) => toPlainText(n)).join('');
+  const children = node.children.map((n) => toPlainText(n, isMarkdown)).join('');
   return elementToPlainText(node, children);
 };
 
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 897cdd48..c4befef6 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -255,7 +255,7 @@ export const RoomInput = forwardRef(
 
       const commandName = getBeginCommand(editor);
 
-      let plainText = toPlainText(editor.children).trim();
+      let plainText = toPlainText(editor.children, isMarkdown).trim();
       let customHtml = trimCustomHtml(
         toMatrixCustomHTML(editor.children, {
           allowTextFormatting: true,
diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx
index 0c995030..deeb8215 100644
--- a/src/app/features/room/message/MessageEditor.tsx
+++ b/src/app/features/room/message/MessageEditor.tsx
@@ -92,7 +92,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
 
     const [saveState, save] = useAsyncCallback(
       useCallback(async () => {
-        const plainText = toPlainText(editor.children).trim();
+        const plainText = toPlainText(editor.children, isMarkdown).trim();
         const customHtml = trimCustomHtml(
           toMatrixCustomHTML(editor.children, {
             allowTextFormatting: true,
@@ -192,8 +192,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
 
       const initialValue =
         typeof customHtml === 'string'
-          ? htmlToEditorInput(customHtml)
-          : plainToEditorInput(typeof body === 'string' ? body : '');
+          ? htmlToEditorInput(customHtml, isMarkdown)
+          : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
 
       Transforms.select(editor, {
         anchor: Editor.start(editor, []),
@@ -202,7 +202,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
 
       editor.insertFragment(initialValue);
       if (!mobileOrTablet()) ReactEditor.focus(editor);
-    }, [editor, getPrevBodyAndFormattedBody]);
+    }, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
 
     useEffect(() => {
       if (saveState.status === AsyncStatus.Success) {
diff --git a/src/app/plugins/markdown.ts b/src/app/plugins/markdown.ts
deleted file mode 100644
index 9b3b82f7..00000000
--- a/src/app/plugins/markdown.ts
+++ /dev/null
@@ -1,368 +0,0 @@
-export type MatchResult = RegExpMatchArray | RegExpExecArray;
-export type RuleMatch = (text: string) => MatchResult | null;
-
-export const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
-  text.slice(0, match.index);
-export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
-  text.slice((match.index ?? 0) + match[0].length);
-
-export const replaceMatch = (
-  convertPart: (txt: string) => Array,
-  text: string,
-  match: MatchResult,
-  content: C
-): Array => [
-  ...convertPart(beforeMatch(text, match)),
-  content,
-  ...convertPart(afterMatch(text, match)),
-];
-
-/*
- *****************
- * INLINE PARSER *
- *****************
- */
-
-export type InlineMDParser = (text: string) => string;
-
-export type InlineMatchConverter = (parse: InlineMDParser, match: MatchResult) => string;
-
-export type InlineMDRule = {
-  match: RuleMatch;
-  html: InlineMatchConverter;
-};
-
-export type InlineRuleRunner = (
-  parse: InlineMDParser,
-  text: string,
-  rule: InlineMDRule
-) => string | undefined;
-export type InlineRulesRunner = (
-  parse: InlineMDParser,
-  text: string,
-  rules: InlineMDRule[]
-) => string | undefined;
-
-const MIN_ANY = '(.+?)';
-const URL_NEG_LB = '(? text.match(BOLD_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const ITALIC_MD_1 = '*';
-const ITALIC_PREFIX_1 = '\\*';
-const ITALIC_NEG_LA_1 = '(?!\\*)';
-const ITALIC_REG_1 = new RegExp(
-  `${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}`
-);
-const ItalicRule1: InlineMDRule = {
-  match: (text) => text.match(ITALIC_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const ITALIC_MD_2 = '_';
-const ITALIC_PREFIX_2 = '_';
-const ITALIC_NEG_LA_2 = '(?!_)';
-const ITALIC_REG_2 = new RegExp(
-  `${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}`
-);
-const ItalicRule2: InlineMDRule = {
-  match: (text) => text.match(ITALIC_REG_2),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const UNDERLINE_MD_1 = '__';
-const UNDERLINE_PREFIX_1 = '_{2}';
-const UNDERLINE_NEG_LA_1 = '(?!_)';
-const UNDERLINE_REG_1 = new RegExp(
-  `${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}`
-);
-const UnderlineRule: InlineMDRule = {
-  match: (text) => text.match(UNDERLINE_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const STRIKE_MD_1 = '~~';
-const STRIKE_PREFIX_1 = '~{2}';
-const STRIKE_NEG_LA_1 = '(?!~)';
-const STRIKE_REG_1 = new RegExp(
-  `${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}`
-);
-const StrikeRule: InlineMDRule = {
-  match: (text) => text.match(STRIKE_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const CODE_MD_1 = '`';
-const CODE_PREFIX_1 = '`';
-const CODE_NEG_LA_1 = '(?!`)';
-const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`);
-const CodeRule: InlineMDRule = {
-  match: (text) => text.match(CODE_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${g2}`;
-  },
-};
-
-const SPOILER_MD_1 = '||';
-const SPOILER_PREFIX_1 = '\\|{2}';
-const SPOILER_NEG_LA_1 = '(?!\\|)';
-const SPOILER_REG_1 = new RegExp(
-  `${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}`
-);
-const SpoilerRule: InlineMDRule = {
-  match: (text) => text.match(SPOILER_REG_1),
-  html: (parse, match) => {
-    const [, , g2] = match;
-    return `${parse(g2)}`;
-  },
-};
-
-const LINK_ALT = `\\[${MIN_ANY}\\]`;
-const LINK_URL = `\\((https?:\\/\\/.+?)\\)`;
-const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`);
-const LinkRule: InlineMDRule = {
-  match: (text) => text.match(LINK_REG_1),
-  html: (parse, match) => {
-    const [, g1, g2] = match;
-    return `${parse(g1)}`;
-  },
-};
-
-const runInlineRule: InlineRuleRunner = (parse, text, rule) => {
-  const matchResult = rule.match(text);
-  if (matchResult) {
-    const content = rule.html(parse, matchResult);
-    return replaceMatch((txt) => [parse(txt)], text, matchResult, content).join('');
-  }
-  return undefined;
-};
-
-/**
- * Runs multiple rules at the same time to better handle nested rules.
- * Rules will be run in the order they appear.
- */
-const runInlineRules: InlineRulesRunner = (parse, text, rules) => {
-  const matchResults = rules.map((rule) => rule.match(text));
-
-  let targetRule: InlineMDRule | undefined;
-  let targetResult: MatchResult | undefined;
-
-  for (let i = 0; i < matchResults.length; i += 1) {
-    const currentResult = matchResults[i];
-    if (currentResult && typeof currentResult.index === 'number') {
-      if (
-        !targetResult ||
-        (typeof targetResult?.index === 'number' && currentResult.index < targetResult.index)
-      ) {
-        targetResult = currentResult;
-        targetRule = rules[i];
-      }
-    }
-  }
-
-  if (targetRule && targetResult) {
-    const content = targetRule.html(parse, targetResult);
-    return replaceMatch((txt) => [parse(txt)], text, targetResult, content).join('');
-  }
-  return undefined;
-};
-
-const LeveledRules = [
-  BoldRule,
-  ItalicRule1,
-  UnderlineRule,
-  ItalicRule2,
-  StrikeRule,
-  SpoilerRule,
-  LinkRule,
-];
-
-export const parseInlineMD: InlineMDParser = (text) => {
-  if (text === '') return text;
-  let result: string | undefined;
-  if (!result) result = runInlineRule(parseInlineMD, text, CodeRule);
-
-  if (!result) result = runInlineRules(parseInlineMD, text, LeveledRules);
-
-  return result ?? text;
-};
-
-/*
- ****************
- * BLOCK PARSER *
- ****************
- */
-
-export type BlockMDParser = (test: string, parseInline?: (txt: string) => string) => string;
-
-export type BlockMatchConverter = (
-  match: MatchResult,
-  parseInline?: (txt: string) => string
-) => string;
-
-export type BlockMDRule = {
-  match: RuleMatch;
-  html: BlockMatchConverter;
-};
-
-export type BlockRuleRunner = (
-  parse: BlockMDParser,
-  text: string,
-  rule: BlockMDRule,
-  parseInline?: (txt: string) => string
-) => string | undefined;
-
-const HEADING_REG_1 = /^(#{1,6}) +(.+)\n?/m;
-const HeadingRule: BlockMDRule = {
-  match: (text) => text.match(HEADING_REG_1),
-  html: (match, parseInline) => {
-    const [, g1, g2] = match;
-    const level = g1.length;
-    return `${parseInline ? parseInline(g2) : g2}`;
-  },
-};
-
-const CODEBLOCK_MD_1 = '```';
-const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m;
-const CodeBlockRule: BlockMDRule = {
-  match: (text) => text.match(CODEBLOCK_REG_1),
-  html: (match) => {
-    const [, g1, g2] = match;
-    const classNameAtt = g1 ? ` class="language-${g1}"` : '';
-    return `
${g2}
`; - }, -}; - -const BLOCKQUOTE_MD_1 = '>'; -const QUOTE_LINE_PREFIX = /^> */; -const BLOCKQUOTE_TRAILING_NEWLINE = /\n$/; -const BLOCKQUOTE_REG_1 = /(^>.*\n?)+/m; -const BlockQuoteRule: BlockMDRule = { - match: (text) => text.match(BLOCKQUOTE_REG_1), - html: (match, parseInline) => { - const [blockquoteText] = match; - - const lines = blockquoteText - .replace(BLOCKQUOTE_TRAILING_NEWLINE, '') - .split('\n') - .map((lineText) => { - const line = lineText.replace(QUOTE_LINE_PREFIX, ''); - if (parseInline) return `${parseInline(line)}
`; - return `${line}
`; - }) - .join(''); - return `
${lines}
`; - }, -}; - -const ORDERED_LIST_MD_1 = '-'; -const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */; -const O_LIST_START = /^([\d])\./; -const O_LIST_TYPE = /^([aAiI])\./; -const O_LIST_TRAILING_NEWLINE = /\n$/; -const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m; -const OrderedListRule: BlockMDRule = { - match: (text) => text.match(ORDERED_LIST_REG_1), - html: (match, parseInline) => { - const [listText] = match; - const [, listStart] = listText.match(O_LIST_START) ?? []; - const [, listType] = listText.match(O_LIST_TYPE) ?? []; - - const lines = listText - .replace(O_LIST_TRAILING_NEWLINE, '') - .split('\n') - .map((lineText) => { - const line = lineText.replace(O_LIST_ITEM_PREFIX, ''); - const txt = parseInline ? parseInline(line) : line; - return `
  • ${txt}

  • `; - }) - .join(''); - - const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`; - const startAtt = listStart ? ` start="${listStart}"` : ''; - const typeAtt = listType ? ` type="${listType}"` : ''; - return `
      ${lines}
    `; - }, -}; - -const UNORDERED_LIST_MD_1 = '*'; -const U_LIST_ITEM_PREFIX = /^\* */; -const U_LIST_TRAILING_NEWLINE = /\n$/; -const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m; -const UnorderedListRule: BlockMDRule = { - match: (text) => text.match(UNORDERED_LIST_REG_1), - html: (match, parseInline) => { - const [listText] = match; - - const lines = listText - .replace(U_LIST_TRAILING_NEWLINE, '') - .split('\n') - .map((lineText) => { - const line = lineText.replace(U_LIST_ITEM_PREFIX, ''); - const txt = parseInline ? parseInline(line) : line; - return `
  • ${txt}

  • `; - }) - .join(''); - - return `
      ${lines}
    `; - }, -}; - -const runBlockRule: BlockRuleRunner = (parse, text, rule, parseInline) => { - const matchResult = rule.match(text); - if (matchResult) { - const content = rule.html(matchResult, parseInline); - return replaceMatch((txt) => [parse(txt, parseInline)], text, matchResult, content).join(''); - } - return undefined; -}; - -export const parseBlockMD: BlockMDParser = (text, parseInline) => { - if (text === '') return text; - let result: string | undefined; - - if (!result) result = runBlockRule(parseBlockMD, text, CodeBlockRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, BlockQuoteRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, OrderedListRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, UnorderedListRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, HeadingRule, parseInline); - - // replace \n with
    because want to preserve empty lines - if (!result) { - if (parseInline) { - result = text - .split('\n') - .map((lineText) => parseInline(lineText)) - .join('
    '); - } else { - result = text.replace(/\n/g, '
    '); - } - } - - return result ?? text; -}; diff --git a/src/app/plugins/markdown/block/index.ts b/src/app/plugins/markdown/block/index.ts new file mode 100644 index 00000000..75aa8b93 --- /dev/null +++ b/src/app/plugins/markdown/block/index.ts @@ -0,0 +1 @@ +export * from './parser'; diff --git a/src/app/plugins/markdown/block/parser.ts b/src/app/plugins/markdown/block/parser.ts new file mode 100644 index 00000000..ed16a327 --- /dev/null +++ b/src/app/plugins/markdown/block/parser.ts @@ -0,0 +1,47 @@ +import { replaceMatch } from '../internal'; +import { + BlockQuoteRule, + CodeBlockRule, + ESC_BLOCK_SEQ, + HeadingRule, + OrderedListRule, + UnorderedListRule, +} from './rules'; +import { runBlockRule } from './runner'; +import { BlockMDParser } from './type'; + +/** + * Parses block-level markdown text into HTML using defined block rules. + * + * @param text - The markdown text to be parsed. + * @param parseInline - Optional function to parse inline elements. + * @returns The parsed HTML or the original text if no block-level markdown was found. + */ +export const parseBlockMD: BlockMDParser = (text, parseInline) => { + if (text === '') return text; + let result: string | undefined; + + if (!result) result = runBlockRule(text, CodeBlockRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, BlockQuoteRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, OrderedListRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, UnorderedListRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, HeadingRule, parseBlockMD, parseInline); + + // replace \n with
    because want to preserve empty lines + if (!result) { + result = text + .split('\n') + .map((lineText) => { + const match = lineText.match(ESC_BLOCK_SEQ); + if (!match) { + return parseInline?.(lineText) ?? lineText; + } + + const [, g1] = match; + return replaceMatch(lineText, match, g1, (t) => [parseInline?.(t) ?? t]).join(''); + }) + .join('
    '); + } + + return result ?? text; +}; diff --git a/src/app/plugins/markdown/block/rules.ts b/src/app/plugins/markdown/block/rules.ts new file mode 100644 index 00000000..f598ee63 --- /dev/null +++ b/src/app/plugins/markdown/block/rules.ts @@ -0,0 +1,100 @@ +import { BlockMDRule } from './type'; + +const HEADING_REG_1 = /^(#{1,6}) +(.+)\n?/m; +export const HeadingRule: BlockMDRule = { + match: (text) => text.match(HEADING_REG_1), + html: (match, parseInline) => { + const [, g1, g2] = match; + const level = g1.length; + return `${parseInline ? parseInline(g2) : g2}`; + }, +}; + +const CODEBLOCK_MD_1 = '```'; +const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m; +export const CodeBlockRule: BlockMDRule = { + match: (text) => text.match(CODEBLOCK_REG_1), + html: (match) => { + const [, g1, g2] = match; + const classNameAtt = g1 ? ` class="language-${g1}"` : ''; + return `
    ${g2}
    `; + }, +}; + +const BLOCKQUOTE_MD_1 = '>'; +const QUOTE_LINE_PREFIX = /^> */; +const BLOCKQUOTE_TRAILING_NEWLINE = /\n$/; +const BLOCKQUOTE_REG_1 = /(^>.*\n?)+/m; +export const BlockQuoteRule: BlockMDRule = { + match: (text) => text.match(BLOCKQUOTE_REG_1), + html: (match, parseInline) => { + const [blockquoteText] = match; + + const lines = blockquoteText + .replace(BLOCKQUOTE_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(QUOTE_LINE_PREFIX, ''); + if (parseInline) return `${parseInline(line)}
    `; + return `${line}
    `; + }) + .join(''); + return `
    ${lines}
    `; + }, +}; + +const ORDERED_LIST_MD_1 = '-'; +const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */; +const O_LIST_START = /^([\d])\./; +const O_LIST_TYPE = /^([aAiI])\./; +const O_LIST_TRAILING_NEWLINE = /\n$/; +const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m; +export const OrderedListRule: BlockMDRule = { + match: (text) => text.match(ORDERED_LIST_REG_1), + html: (match, parseInline) => { + const [listText] = match; + const [, listStart] = listText.match(O_LIST_START) ?? []; + const [, listType] = listText.match(O_LIST_TYPE) ?? []; + + const lines = listText + .replace(O_LIST_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(O_LIST_ITEM_PREFIX, ''); + const txt = parseInline ? parseInline(line) : line; + return `
  • ${txt}

  • `; + }) + .join(''); + + const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`; + const startAtt = listStart ? ` start="${listStart}"` : ''; + const typeAtt = listType ? ` type="${listType}"` : ''; + return `
      ${lines}
    `; + }, +}; + +const UNORDERED_LIST_MD_1 = '*'; +const U_LIST_ITEM_PREFIX = /^\* */; +const U_LIST_TRAILING_NEWLINE = /\n$/; +const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m; +export const UnorderedListRule: BlockMDRule = { + match: (text) => text.match(UNORDERED_LIST_REG_1), + html: (match, parseInline) => { + const [listText] = match; + + const lines = listText + .replace(U_LIST_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(U_LIST_ITEM_PREFIX, ''); + const txt = parseInline ? parseInline(line) : line; + return `
  • ${txt}

  • `; + }) + .join(''); + + return `
      ${lines}
    `; + }, +}; + +export const UN_ESC_BLOCK_SEQ = /^\\*(#{1,6} +|```|>|(-|[\da-zA-Z]\.) +|\* +)/; +export const ESC_BLOCK_SEQ = /^\\(\\*(#{1,6} +|```|>|(-|[\da-zA-Z]\.) +|\* +))/; diff --git a/src/app/plugins/markdown/block/runner.ts b/src/app/plugins/markdown/block/runner.ts new file mode 100644 index 00000000..1dc8d8b8 --- /dev/null +++ b/src/app/plugins/markdown/block/runner.ts @@ -0,0 +1,25 @@ +import { replaceMatch } from '../internal'; +import { BlockMDParser, BlockMDRule } from './type'; + +/** + * Parses block-level markdown text into HTML using defined block rules. + * + * @param text - The text to parse. + * @param rule - The markdown rule to run. + * @param parse - A function that run the parser on remaining parts.. + * @param parseInline - Optional function to parse inline elements. + * @returns The text with the markdown rule applied or `undefined` if no match is found. + */ +export const runBlockRule = ( + text: string, + rule: BlockMDRule, + parse: BlockMDParser, + parseInline?: (txt: string) => string +): string | undefined => { + const matchResult = rule.match(text); + if (matchResult) { + const content = rule.html(matchResult, parseInline); + return replaceMatch(text, matchResult, content, (txt) => [parse(txt, parseInline)]).join(''); + } + return undefined; +}; diff --git a/src/app/plugins/markdown/block/type.ts b/src/app/plugins/markdown/block/type.ts new file mode 100644 index 00000000..0949eb70 --- /dev/null +++ b/src/app/plugins/markdown/block/type.ts @@ -0,0 +1,30 @@ +import { MatchResult, MatchRule } from '../internal'; + +/** + * Type for a function that parses block-level markdown into HTML. + * + * @param text - The markdown text to be parsed. + * @param parseInline - Optional function to parse inline elements. + * @returns The parsed HTML. + */ +export type BlockMDParser = (text: string, parseInline?: (txt: string) => string) => string; + +/** + * Type for a function that converts a block match to output. + * + * @param match - The match result. + * @param parseInline - Optional function to parse inline elements. + * @returns The output string after processing the match. + */ +export type BlockMatchConverter = ( + match: MatchResult, + parseInline?: (txt: string) => string +) => string; + +/** + * Type representing a block-level markdown rule that includes a matching pattern and HTML conversion. + */ +export type BlockMDRule = { + match: MatchRule; // A function that matches a specific markdown pattern. + html: BlockMatchConverter; // A function that converts the match to HTML. +}; diff --git a/src/app/plugins/markdown/index.ts b/src/app/plugins/markdown/index.ts new file mode 100644 index 00000000..4c4e4491 --- /dev/null +++ b/src/app/plugins/markdown/index.ts @@ -0,0 +1,3 @@ +export * from './utils'; +export * from './block'; +export * from './inline'; diff --git a/src/app/plugins/markdown/inline/index.ts b/src/app/plugins/markdown/inline/index.ts new file mode 100644 index 00000000..75aa8b93 --- /dev/null +++ b/src/app/plugins/markdown/inline/index.ts @@ -0,0 +1 @@ +export * from './parser'; diff --git a/src/app/plugins/markdown/inline/parser.ts b/src/app/plugins/markdown/inline/parser.ts new file mode 100644 index 00000000..37c71a66 --- /dev/null +++ b/src/app/plugins/markdown/inline/parser.ts @@ -0,0 +1,40 @@ +import { + BoldRule, + CodeRule, + EscapeRule, + ItalicRule1, + ItalicRule2, + LinkRule, + SpoilerRule, + StrikeRule, + UnderlineRule, +} from './rules'; +import { runInlineRule, runInlineRules } from './runner'; +import { InlineMDParser } from './type'; + +const LeveledRules = [ + BoldRule, + ItalicRule1, + UnderlineRule, + ItalicRule2, + StrikeRule, + SpoilerRule, + LinkRule, + EscapeRule, +]; + +/** + * Parses inline markdown text into HTML using defined rules. + * + * @param text - The markdown text to be parsed. + * @returns The parsed HTML or the original text if no markdown was found. + */ +export const parseInlineMD: InlineMDParser = (text) => { + if (text === '') return text; + let result: string | undefined; + if (!result) result = runInlineRule(text, CodeRule, parseInlineMD); + + if (!result) result = runInlineRules(text, LeveledRules, parseInlineMD); + + return result ?? text; +}; diff --git a/src/app/plugins/markdown/inline/rules.ts b/src/app/plugins/markdown/inline/rules.ts new file mode 100644 index 00000000..bc76f60a --- /dev/null +++ b/src/app/plugins/markdown/inline/rules.ts @@ -0,0 +1,123 @@ +import { InlineMDRule } from './type'; + +const MIN_ANY = '(.+?)'; +const URL_NEG_LB = '(? text.match(BOLD_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const ITALIC_MD_1 = '*'; +const ITALIC_PREFIX_1 = `${ESC_NEG_LB}\\*`; +const ITALIC_NEG_LA_1 = '(?!\\*)'; +const ITALIC_REG_1 = new RegExp( + `${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}` +); +export const ItalicRule1: InlineMDRule = { + match: (text) => text.match(ITALIC_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const ITALIC_MD_2 = '_'; +const ITALIC_PREFIX_2 = `${ESC_NEG_LB}_`; +const ITALIC_NEG_LA_2 = '(?!_)'; +const ITALIC_REG_2 = new RegExp( + `${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}` +); +export const ItalicRule2: InlineMDRule = { + match: (text) => text.match(ITALIC_REG_2), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const UNDERLINE_MD_1 = '__'; +const UNDERLINE_PREFIX_1 = `${ESC_NEG_LB}_{2}`; +const UNDERLINE_NEG_LA_1 = '(?!_)'; +const UNDERLINE_REG_1 = new RegExp( + `${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}` +); +export const UnderlineRule: InlineMDRule = { + match: (text) => text.match(UNDERLINE_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const STRIKE_MD_1 = '~~'; +const STRIKE_PREFIX_1 = `${ESC_NEG_LB}~{2}`; +const STRIKE_NEG_LA_1 = '(?!~)'; +const STRIKE_REG_1 = new RegExp( + `${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}` +); +export const StrikeRule: InlineMDRule = { + match: (text) => text.match(STRIKE_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const CODE_MD_1 = '`'; +const CODE_PREFIX_1 = `${ESC_NEG_LB}\``; +const CODE_NEG_LA_1 = '(?!`)'; +const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`); +export const CodeRule: InlineMDRule = { + match: (text) => text.match(CODE_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${g2}`; + }, +}; + +const SPOILER_MD_1 = '||'; +const SPOILER_PREFIX_1 = `${ESC_NEG_LB}\\|{2}`; +const SPOILER_NEG_LA_1 = '(?!\\|)'; +const SPOILER_REG_1 = new RegExp( + `${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}` +); +export const SpoilerRule: InlineMDRule = { + match: (text) => text.match(SPOILER_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const LINK_ALT = `\\[${MIN_ANY}\\]`; +const LINK_URL = `\\((https?:\\/\\/.+?)\\)`; +const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`); +export const LinkRule: InlineMDRule = { + match: (text) => text.match(LINK_REG_1), + html: (parse, match) => { + const [, g1, g2] = match; + return `${parse(g1)}`; + }, +}; + +export const INLINE_SEQUENCE_SET = '[*_~`|]'; +const ESC_SEQ_1 = `\\\\(${INLINE_SEQUENCE_SET})`; +const ESC_REG_1 = new RegExp(`${URL_NEG_LB}${ESC_SEQ_1}`); +export const EscapeRule: InlineMDRule = { + match: (text) => text.match(ESC_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return g2; + }, +}; diff --git a/src/app/plugins/markdown/inline/runner.ts b/src/app/plugins/markdown/inline/runner.ts new file mode 100644 index 00000000..3a794d25 --- /dev/null +++ b/src/app/plugins/markdown/inline/runner.ts @@ -0,0 +1,62 @@ +import { MatchResult, replaceMatch } from '../internal'; +import { InlineMDParser, InlineMDRule } from './type'; + +/** + * Runs a single markdown rule on the provided text. + * + * @param text - The text to parse. + * @param rule - The markdown rule to run. + * @param parse - A function that run the parser on remaining parts. + * @returns The text with the markdown rule applied or `undefined` if no match is found. + */ +export const runInlineRule = ( + text: string, + rule: InlineMDRule, + parse: InlineMDParser +): string | undefined => { + const matchResult = rule.match(text); + if (matchResult) { + const content = rule.html(parse, matchResult); + return replaceMatch(text, matchResult, content, (txt) => [parse(txt)]).join(''); + } + return undefined; +}; + +/** + * Runs multiple rules at the same time to better handle nested rules. + * Rules will be run in the order they appear. + * + * @param text - The text to parse. + * @param rules - The markdown rules to run. + * @param parse - A function that run the parser on remaining parts. + * @returns The text with the markdown rules applied or `undefined` if no match is found. + */ +export const runInlineRules = ( + text: string, + rules: InlineMDRule[], + parse: InlineMDParser +): string | undefined => { + const matchResults = rules.map((rule) => rule.match(text)); + + let targetRule: InlineMDRule | undefined; + let targetResult: MatchResult | undefined; + + for (let i = 0; i < matchResults.length; i += 1) { + const currentResult = matchResults[i]; + if (currentResult && typeof currentResult.index === 'number') { + if ( + !targetResult || + (typeof targetResult?.index === 'number' && currentResult.index < targetResult.index) + ) { + targetResult = currentResult; + targetRule = rules[i]; + } + } + } + + if (targetRule && targetResult) { + const content = targetRule.html(parse, targetResult); + return replaceMatch(text, targetResult, content, (txt) => [parse(txt)]).join(''); + } + return undefined; +}; diff --git a/src/app/plugins/markdown/inline/type.ts b/src/app/plugins/markdown/inline/type.ts new file mode 100644 index 00000000..a65ad276 --- /dev/null +++ b/src/app/plugins/markdown/inline/type.ts @@ -0,0 +1,26 @@ +import { MatchResult, MatchRule } from '../internal'; + +/** + * Type for a function that parses inline markdown into HTML. + * + * @param text - The markdown text to be parsed. + * @returns The parsed HTML. + */ +export type InlineMDParser = (text: string) => string; + +/** + * Type for a function that converts a match to output. + * + * @param parse - The inline markdown parser function. + * @param match - The match result. + * @returns The output string after processing the match. + */ +export type InlineMatchConverter = (parse: InlineMDParser, match: MatchResult) => string; + +/** + * Type representing a markdown rule that includes a matching pattern and HTML conversion. + */ +export type InlineMDRule = { + match: MatchRule; // A function that matches a specific markdown pattern. + html: InlineMatchConverter; // A function that converts the match to HTML. +}; diff --git a/src/app/plugins/markdown/internal/index.ts b/src/app/plugins/markdown/internal/index.ts new file mode 100644 index 00000000..04bca77e --- /dev/null +++ b/src/app/plugins/markdown/internal/index.ts @@ -0,0 +1 @@ +export * from './utils'; diff --git a/src/app/plugins/markdown/internal/utils.ts b/src/app/plugins/markdown/internal/utils.ts new file mode 100644 index 00000000..86bc9d5c --- /dev/null +++ b/src/app/plugins/markdown/internal/utils.ts @@ -0,0 +1,61 @@ +/** + * @typedef {RegExpMatchArray | RegExpExecArray} MatchResult + * + * Represents the result of a regular expression match. + * This type can be either a `RegExpMatchArray` or a `RegExpExecArray`, + * which are returned when performing a match with a regular expression. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match} + */ +export type MatchResult = RegExpMatchArray | RegExpExecArray; + +/** + * @typedef {function(string): MatchResult | null} MatchRule + * + * A function type that takes a string and returns a `MatchResult` or `null` if no match is found. + * + * @param {string} text The string to match against. + * @returns {MatchResult | null} The result of the regular expression match, or `null` if no match is found. + */ +export type MatchRule = (text: string) => MatchResult | null; + +/** + * Returns the part of the text before a match. + * + * @param text - The input text string. + * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). + * @returns A string containing the part of the text before the match. + */ +export const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => + text.slice(0, match.index); + +/** + * Returns the part of the text after a match. + * + * @param text - The input text string. + * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). + * @returns A string containing the part of the text after the match. + */ +export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => + text.slice((match.index ?? 0) + match[0].length); + +/** + * Replaces a match in the text with a content. + * + * @param text - The input text string. + * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). + * @param content - The content to replace the match with. + * @param processPart - A function to further process remaining parts of the text. + * @returns An array containing the processed parts of the text, including the content. + */ +export const replaceMatch = ( + text: string, + match: MatchResult, + content: C, + processPart: (txt: string) => Array +): Array => [ + ...processPart(beforeMatch(text, match)), + content, + ...processPart(afterMatch(text, match)), +]; diff --git a/src/app/plugins/markdown/utils.ts b/src/app/plugins/markdown/utils.ts new file mode 100644 index 00000000..5ebd958c --- /dev/null +++ b/src/app/plugins/markdown/utils.ts @@ -0,0 +1,83 @@ +import { findAndReplace } from '../../utils/findAndReplace'; +import { ESC_BLOCK_SEQ, UN_ESC_BLOCK_SEQ } from './block/rules'; +import { EscapeRule, INLINE_SEQUENCE_SET } from './inline/rules'; +import { runInlineRule } from './inline/runner'; +import { replaceMatch } from './internal'; + +/** + * Removes escape sequences from markdown inline elements in the given plain-text. + * This function unescapes characters that are escaped with backslashes (e.g., `\*`, `\_`) + * in markdown syntax, returning the original plain-text with markdown characters in effect. + * + * @param text - The input markdown plain-text containing escape characters (e.g., `"some \*italic\*"`) + * @returns The plain-text with markdown escape sequences removed (e.g., `"some *italic*"`) + */ +export const unescapeMarkdownInlineSequences = (text: string): string => + runInlineRule(text, EscapeRule, (t) => { + if (t === '') return t; + return unescapeMarkdownInlineSequences(t); + }) ?? text; + +/** + * Recovers the markdown escape sequences in the given plain-text. + * This function adds backslashes (`\`) before markdown characters that may need escaping + * (e.g., `*`, `_`) to ensure they are treated as literal characters and not part of markdown formatting. + * + * @param text - The input plain-text that may contain markdown sequences (e.g., `"some *italic*"`) + * @returns The plain-text with markdown escape sequences added (e.g., `"some \*italic\*"`) + */ +export const escapeMarkdownInlineSequences = (text: string): string => { + const regex = new RegExp(`(${INLINE_SEQUENCE_SET})`, 'g'); + const parts = findAndReplace( + text, + regex, + (match) => { + const [, g1] = match; + return `\\${g1}`; + }, + (t) => t + ); + + return parts.join(''); +}; + +/** + * Removes escape sequences from markdown block elements in the given plain-text. + * This function unescapes characters that are escaped with backslashes (e.g., `\>`, `\#`) + * in markdown syntax, returning the original plain-text with markdown characters in effect. + * + * @param {string} text - The input markdown plain-text containing escape characters (e.g., `\> block quote`). + * @param {function} processPart - It takes the plain-text as input and returns a modified version of it. + * @returns {string} The plain-text with markdown escape sequences removed and markdown formatting applied. + */ +export const unescapeMarkdownBlockSequences = ( + text: string, + processPart: (text: string) => string +): string => { + const match = text.match(ESC_BLOCK_SEQ); + + if (!match) return processPart(text); + + const [, g1] = match; + return replaceMatch(text, match, g1, (t) => [processPart(t)]).join(''); +}; + +/** + * Escapes markdown block elements by adding backslashes before markdown characters + * (e.g., `\>`, `\#`) that are normally interpreted as markdown syntax. + * + * @param {string} text - The input markdown plain-text that may contain markdown elements (e.g., `> block quote`). + * @param {function} processPart - It takes the plain-text as input and returns a modified version of it. + * @returns {string} The plain-text with markdown escape sequences added, preventing markdown formatting. + */ +export const escapeMarkdownBlockSequences = ( + text: string, + processPart: (text: string) => string +): string => { + const match = text.match(UN_ESC_BLOCK_SEQ); + + if (!match) return processPart(text); + + const [, g1] = match; + return replaceMatch(text, match, `\\${g1}`, (t) => [processPart(t)]).join(''); +};