diff --git a/.changeset/add_math_and_markdown.md b/.changeset/add_math_and_markdown.md new file mode 100644 index 000000000..df1886f28 --- /dev/null +++ b/.changeset/add_math_and_markdown.md @@ -0,0 +1,15 @@ +--- +default: minor +--- + +# Markdown parser and render updates + +Migrated markdown parsing and rendering to use marked, which should fix most (all?) markdown issues involving lists/nested structures, inconsistent/inaccurate code blocks, escape sequences, and all the other bugs with literally everything. + +Added math rendering support via marked and KaTeX, uses standard `$$` and `$` delimiters. Only renders a subset of latex tags that will likely need to be expanded so feel free to make issues if needed. + +Also adds support for sending markdown tables (although they're rendered rather plainly at the moment), sending valid html directly (such as for colored text), and properly escaping anything with backslashes. + +Fixes link previews appearing in code blocks, fixes pmp new line behavior, fixes links not opening in new tabs, and fixes editing arbitrary html messages, probably. + +Finally, the old WYSIWYG editor has been completely removed. diff --git a/package.json b/package.json index 47bfe902f..69d23b0fc 100644 --- a/package.json +++ b/package.json @@ -70,8 +70,10 @@ "immer": "^9.0.21", "is-hotkey": "^0.2.0", "jotai": "^2.18.0", + "katex": "^0.16.45", "linkify-react": "^4.3.2", "linkifyjs": "^4.3.2", + "marked": "^18.0.2", "matrix-js-sdk": "^38.4.0", "matrix-widget-api": "^1.16.1", "pdfjs-dist": "^5.4.624", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02f7aba34..b5c82dbe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,12 +135,18 @@ importers: jotai: specifier: ^2.18.0 version: 2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1) + katex: + specifier: ^0.16.45 + version: 0.16.45 linkify-react: specifier: ^4.3.2 version: 4.3.2(linkifyjs@4.3.2)(react@18.3.1) linkifyjs: specifier: ^4.3.2 version: 4.3.2 + marked: + specifier: ^18.0.2 + version: 18.0.2 matrix-js-sdk: specifier: ^38.4.0 version: 38.4.0 @@ -3276,6 +3282,10 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + common-tags@1.8.2: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} @@ -4039,6 +4049,10 @@ packages: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} + katex@0.16.45: + resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} + hasBin: true + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -4122,6 +4136,11 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + marked@18.0.2: + resolution: {integrity: sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -8407,6 +8426,8 @@ snapshots: commander@2.20.3: {} + commander@8.3.0: {} + common-tags@1.8.2: {} compute-scroll-into-view@3.1.1: {} @@ -9224,6 +9245,10 @@ snapshots: jwt-decode@4.0.0: {} + katex@0.16.45: + dependencies: + commander: 8.3.0 + kleur@4.1.5: {} knip@5.85.0(@types/node@24.10.13)(typescript@5.9.3): @@ -9304,6 +9329,8 @@ snapshots: dependencies: semver: 7.7.4 + marked@18.0.2: {} + math-intrinsics@1.1.0: {} matrix-events-sdk@0.0.1: {} diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index 22487c03e..410d9aa98 100644 --- a/src/app/components/editor/Elements.tsx +++ b/src/app/components/editor/Elements.tsx @@ -1,4 +1,4 @@ -import { Scroll, Text } from 'folds'; +import { Text } from 'folds'; import type { RenderElementProps, RenderLeafProps } from 'slate-react'; import { useFocused, useSelected, useSlate } from 'slate-react'; import { useAtomValue } from 'jotai'; @@ -130,72 +130,6 @@ export function RenderElement({ attributes, element, children }: RenderElementPr {children} ); - case BlockType.Heading: - if (element.level === 1) - return ( - - {children} - - ); - if (element.level === 2) - return ( - - {children} - - ); - if (element.level === 3) - return ( - - {children} - - ); - return ( - - {children} - - ); - case BlockType.CodeLine: - return
{children}
; - case BlockType.CodeBlock: - return ( - - -
{children}
-
-
- ); - case BlockType.QuoteLine: - return
{children}
; - case BlockType.BlockQuote: - return ( - - {children} - - ); - case BlockType.ListItem: - return ( - - {children} - - ); - case BlockType.OrderedList: - return ( -
    - {children} -
- ); - case BlockType.UnorderedList: - return ( - - ); case BlockType.Mention: return ( @@ -220,19 +154,6 @@ export function RenderElement({ attributes, element, children }: RenderElementPr {children} ); - case BlockType.Small: - return ( - - {children} - - ); - case BlockType.HorizontalRule: - return ( -
-
- {children} -
- ); default: return ( - - {child} - - ); - if (leaf.italic) - child = ( - - - {child} - - ); - if (leaf.underline) - child = ( - - - {child} - - ); - if (leaf.strikeThrough) - child = ( - - - {child} - - ); - if (leaf.code) - child = ( - - - {child} - - ); - if (leaf.spoiler) - child = ( - - - {child} - - ); - - if (child !== children) return child; - - return {child}; +export function RenderLeaf({ attributes, children }: RenderLeafProps) { + return {children}; } diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx deleted file mode 100644 index 4028b13c5..000000000 --- a/src/app/components/editor/Toolbar.tsx +++ /dev/null @@ -1,366 +0,0 @@ -import FocusTrap from 'focus-trap-react'; -import type { IconSrc, RectCords } from 'folds'; -import { - Badge, - Box, - config, - Icon, - IconButton, - Icons, - Line, - Menu, - PopOut, - Scroll, - Text, - Tooltip, - TooltipProvider, - toRem, -} from 'folds'; -import type { MouseEventHandler, ReactNode } from 'react'; -import { useState } from 'react'; -import { ReactEditor, useSlate } from 'slate-react'; -import { isMacOS } from '$utils/user-agent'; -import { KeySymbol } from '$utils/key-symbol'; -import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; -import { stopPropagation } from '$utils/keyboard'; -import { floatingToolbar } from '$styles/overrides/Composer.css'; -import type { HeadingLevel } from './slate'; -import { BlockType, MarkType } from './types'; -import * as css from './Editor.css'; -import { - headingLevel, - isAnyMarkActive, - isBlockActive, - isMarkActive, - removeAllMark, - toggleBlock, - toggleMark, -} from './utils'; - -function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) { - return ( - - - {text} - {shortCode && ( - - - {shortCode} - - - )} - - - ); -} - -type MarkButtonProps = { format: MarkType; icon: IconSrc; tooltip: ReactNode }; -export function MarkButton({ format, icon, tooltip }: MarkButtonProps) { - const editor = useSlate(); - const disableInline = isBlockActive(editor, BlockType.CodeBlock); - - if (disableInline) { - removeAllMark(editor); - } - - const handleClick = () => { - toggleMark(editor, format); - ReactEditor.focus(editor); - }; - - return ( - - {(triggerRef) => ( - - - - )} - - ); -} - -type BlockButtonProps = { - format: BlockType; - icon: IconSrc; - tooltip: ReactNode; -}; -export function BlockButton({ format, icon, tooltip }: BlockButtonProps) { - const editor = useSlate(); - - const handleClick = () => { - toggleBlock(editor, format, { level: 1 }); - ReactEditor.focus(editor); - }; - - return ( - - {(triggerRef) => ( - - - - )} - - ); -} - -export function HeadingBlockButton() { - const editor = useSlate(); - const level = headingLevel(editor); - const [anchor, setAnchor] = useState(); - const isActive = isBlockActive(editor, BlockType.Heading); - const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; - - const handleMenuSelect = (selectedLevel: HeadingLevel) => { - setAnchor(undefined); - toggleBlock(editor, BlockType.Heading, { level: selectedLevel }); - ReactEditor.focus(editor); - }; - - const handleMenuOpen: MouseEventHandler = (evt) => { - if (isActive) { - toggleBlock(editor, BlockType.Heading); - return; - } - setAnchor(evt.currentTarget.getBoundingClientRect()); - }; - return ( - setAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - } - delay={500} - > - {(triggerRef) => ( - handleMenuSelect(1)} - size="400" - radii="300" - > - - - )} - - } - delay={500} - > - {(triggerRef) => ( - handleMenuSelect(2)} - size="400" - radii="300" - > - - - )} - - } - delay={500} - > - {(triggerRef) => ( - handleMenuSelect(3)} - size="400" - radii="300" - > - - - )} - - - - - } - > - - - - - - ); -} - -type ExitFormattingProps = { tooltip: ReactNode }; -export function ExitFormatting({ tooltip }: ExitFormattingProps) { - const editor = useSlate(); - - const handleClick = () => { - if (isAnyMarkActive(editor)) { - removeAllMark(editor); - } else if (!isBlockActive(editor, BlockType.Paragraph)) { - toggleBlock(editor, BlockType.Paragraph); - } - ReactEditor.focus(editor); - }; - - return ( - - {(triggerRef) => ( - - {`Exit ${KeySymbol.Hyper}`} - - )} - - ); -} - -export function Toolbar() { - const editor = useSlate(); - const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; - const disableInline = isBlockActive(editor, BlockType.CodeBlock); - - const canEscape = isBlockActive(editor, BlockType.Paragraph) - ? isAnyMarkActive(editor) - : ReactEditor.isFocused(editor); - const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); - - return ( - - - - <> - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - - - - } - /> - } - /> - } - /> - } - /> - - - {canEscape && ( - <> - - - - } - /> - - - )} - - } - delay={500} - > - {(triggerRef) => ( - setIsMarkdown(!isMarkdown)} - aria-pressed={isMarkdown} - size="300" - radii="300" - disabled={disableInline || !!isAnyMarkActive(editor)} - > - - - )} - - - - - - - ); -} diff --git a/src/app/components/editor/getLinks.test.ts b/src/app/components/editor/getLinks.test.ts new file mode 100644 index 000000000..7f830a557 --- /dev/null +++ b/src/app/components/editor/getLinks.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import type { Descendant } from 'slate'; +import { getLinks, toPlainText } from './output'; +import type { ParagraphElement } from './slate'; +import { BlockType } from './types'; + +describe('getLinks', () => { + it('extracts URLs from text', () => { + const node: ParagraphElement = { + type: BlockType.Paragraph, + children: [{ text: 'Check out https://example.com for more info' }], + }; + const links = getLinks([node]); + expect(links).toContain('https://example.com'); + }); + + it('excludes URLs in angle brackets (Matrix HTML spoiler)', () => { + const node: ParagraphElement = { + type: BlockType.Paragraph, + children: [{ text: 'Check out for more info' }], + }; + const links = getLinks([node]); + expect(links).toEqual([]); + }); + + it('extracts markdown link URLs', () => { + const node: ParagraphElement = { + type: BlockType.Paragraph, + children: [{ text: 'Check [my link](https://example.com) for more info' }], + }; + const links = getLinks([node]); + expect(links).toContain('https://example.com'); + }); + + it('excludes URLs inside markdown inline code spans', () => { + const node: ParagraphElement = { + type: BlockType.Paragraph, + children: [{ text: 'Do not visit `https://example.com` please' }], + }; + const links = getLinks([node]); + expect(links).toEqual([]); + }); + + it('excludes URLs inside markdown code blocks spanning multiple paragraphs', () => { + const nodes: Descendant[] = [ + { type: BlockType.Paragraph, children: [{ text: '```' }] }, + { type: BlockType.Paragraph, children: [{ text: 'https://example.com' }] }, + { type: BlockType.Paragraph, children: [{ text: '```' }] }, + ]; + const links = getLinks(nodes); + expect(links).toEqual([]); + }); +}); + +describe('toPlainText spoiler handling', () => { + it('replaces ||spoilered text|| with [Spoiler]', () => { + const node: ParagraphElement = { + type: BlockType.Paragraph, + children: [{ text: 'Hello ||spoilered|| world' }], + }; + const plain = toPlainText(node); + expect(plain).toContain('[Spoiler]'); + expect(plain).not.toContain('||spoilered||'); + }); + + it('replaces ||spoilered links|| with [Spoiler]', () => { + const node: ParagraphElement = { + type: BlockType.Paragraph, + children: [{ text: 'Hello ||https://example.com|| world' }], + }; + const plain = toPlainText(node); + expect(plain).toContain('[Spoiler]'); + expect(plain).not.toContain('||https://example.com||'); + }); + + it('extracts non-spoilered markdown link URLs alongside spoilered ones', () => { + const node: ParagraphElement = { + type: BlockType.Paragraph, + children: [ + { + text: 'Check [visible](https://visible.com) and ||https://hidden.com||', + }, + ], + }; + const links = getLinks([node]); + expect(links).toContain('https://visible.com'); + expect(links).not.toContain('https://hidden.com'); + }); +}); diff --git a/src/app/components/editor/index.ts b/src/app/components/editor/index.ts index aae0137d1..09905d5f2 100644 --- a/src/app/components/editor/index.ts +++ b/src/app/components/editor/index.ts @@ -4,6 +4,6 @@ export * from './Editor'; export * from './Elements'; export * from './keyboard'; export * from './output'; -export * from './Toolbar'; + export * from './input'; export * from './types'; diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index b2476e437..7222cfcae 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -1,501 +1,15 @@ import type { Descendant } from 'slate'; -import { Text } from 'slate'; -import parse from 'html-dom-parser'; -import type { ChildNode, Element } from 'domhandler'; -import { isText, isTag } from 'domhandler'; -import { sanitizeCustomHtml } from '$utils/sanitize'; -import { - parseMatrixToRoom, - parseMatrixToRoomEvent, - parseMatrixToUser, - testMatrixTo, -} from '$plugins/matrix-to'; -import { escapeMarkdownInlineSequences, escapeMarkdownBlockSequences } from '$plugins/markdown'; -import { BlockType, MarkType } from './types'; -import type { - BlockQuoteElement, - CodeBlockElement, - CodeLineElement, - EmoticonElement, - HeadingElement, - HeadingLevel, - HorizontalRuleElement, - InlineElement, - MentionElement, - OrderedListElement, - ParagraphElement, - SmallElement, - UnorderedListElement, -} from './slate'; -import { createEmoticonElement, createMentionElement } from './utils'; +import { BlockType } from './types'; +import type { ParagraphElement } from './slate'; -type ProcessTextCallback = (text: string) => string; - -const getText = (node: ChildNode): string => { - if (isText(node)) { - return node.data; - } - if (isTag(node)) { - return node.children.map((child) => getText(child)).join(''); - } - return ''; -}; - -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;
-  }
-  return children.map((child) => {
-    if (Text.isText(child)) {
-      return { ...child, [markType]: true };
-    }
-    return child;
-  });
-};
-
-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;
-    return createEmoticonElement(src, alt || 'Unknown Emoji');
-  }
-  if (node.name === 'a') {
-    const encodedHref = node.attribs.href;
-    const href = encodedHref && decodeURIComponent(encodedHref);
-    if (!href) return undefined;
-    if (testMatrixTo(href)) {
-      const userMention = parseMatrixToUser(href);
-      if (userMention) {
-        return createMentionElement(userMention, getText(node) || userMention, false);
-      }
-      const roomMention = parseMatrixToRoom(href);
-      if (roomMention) {
-        return createMentionElement(
-          roomMention.roomIdOrAlias,
-          getText(node) || roomMention.roomIdOrAlias,
-          false,
-          undefined,
-          roomMention.viaServers
-        );
-      }
-      const eventMention = parseMatrixToRoomEvent(href);
-      if (eventMention) {
-        return createMentionElement(
-          eventMention.roomIdOrAlias,
-          getText(node) || eventMention.roomIdOrAlias,
-          false,
-          eventMention.eventId,
-          eventMention.viaServers
-        );
-      }
-    }
-  }
-  return undefined;
-};
-
-const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): InlineElement[] => {
-  if (isText(node)) {
-    return [{ text: processText(node.data) }];
-  }
-
-  if (isTag(node)) {
-    const markType = getInlineNodeMarkType(node);
-    if (markType) {
-      return getInlineMarkElement(markType, node, (child) => {
-        if (markType === MarkType.Code) return [{ text: getText(child) }];
-        return getInlineElement(child, processText);
-      });
-    }
-
-    const inlineNode = getInlineNonMarkElement(node);
-    if (inlineNode) return [inlineNode];
-
-    if (node.name === 'a') {
-      const children = node.childNodes.flatMap((child) => getInlineElement(child, processText));
-      children.unshift({ text: '[' });
-      children.push({ text: `](${node.attribs.href})` });
-      return children;
-    }
-
-    return node.childNodes.flatMap((child) => getInlineElement(child, processText));
-  }
-
-  return [];
-};
-
-const parseBlockquoteNode = (
-  node: Element,
-  processText: ProcessTextCallback
-): BlockQuoteElement[] | ParagraphElement[] => {
-  const quoteLines: Array = [];
-  let lineHolder: InlineElement[] = [];
-
-  const appendLine = () => {
-    if (lineHolder.length === 0) return;
-
-    quoteLines.push(lineHolder);
-    lineHolder = [];
-  };
-
-  node.children.forEach((child) => {
-    if (isText(child)) {
-      lineHolder.push({ text: processText(child.data) });
-      return;
-    }
-    if (isTag(child)) {
-      if (child.name === 'br') {
-        lineHolder.push({ text: '' });
-        appendLine();
-        return;
-      }
-
-      if (child.name === 'p') {
-        appendLine();
-        quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
-        return;
-      }
-
-      lineHolder.push(...getInlineElement(child, processText));
-    }
-  });
-  appendLine();
-
-  const mdSequence = node.attribs['data-md'];
-  if (mdSequence !== undefined) {
-    return quoteLines.map((lineChildren) => ({
-      type: BlockType.Paragraph,
-      children: [{ text: `${mdSequence} ` }, ...lineChildren],
-    }));
-  }
-
-  return [
-    {
-      type: BlockType.BlockQuote,
-      children: quoteLines.map((lineChildren) => ({
-        type: BlockType.QuoteLine,
-        children: lineChildren,
-      })),
-    },
-  ];
-};
-const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => {
-  const codeLines = getText(node).trim().split('\n');
-
-  const mdSequence = node.attribs['data-md'];
-  if (mdSequence !== undefined) {
-    const pLines = codeLines.map((text) => ({
-      type: BlockType.Paragraph,
-      children: [{ text }],
-    }));
-    const childCode = node.children[0]!;
-    const className =
-      isTag(childCode) && childCode.tagName === 'code' ? (childCode.attribs.class ?? '') : '';
-    const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
-    const suffix = { text: mdSequence };
-    return [
-      { type: BlockType.Paragraph, children: [prefix] },
-      ...pLines,
-      { type: BlockType.Paragraph, children: [suffix] },
-    ];
-  }
-
-  return [
-    {
-      type: BlockType.CodeBlock,
-      children: codeLines.map((text) => ({
-        type: BlockType.CodeLine,
-        children: [{ text }],
-      })),
-    },
-  ];
-};
-const parseListNode = (
-  node: Element,
-  processText: ProcessTextCallback
-): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
-  const listLines: Array = [];
-  let lineHolder: InlineElement[] = [];
-
-  const appendLine = () => {
-    if (lineHolder.length === 0) return;
-
-    listLines.push(lineHolder);
-    lineHolder = [];
-  };
-
-  node.children.forEach((child) => {
-    if (isText(child)) {
-      lineHolder.push({ text: processText(child.data) });
-      return;
-    }
-    if (isTag(child)) {
-      if (child.name === 'br') {
-        lineHolder.push({ text: '' });
-        appendLine();
-        return;
-      }
-
-      if (child.name === 'li') {
-        appendLine();
-        listLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
-        return;
-      }
-
-      lineHolder.push(...getInlineElement(child, processText));
-    }
-  });
-  appendLine();
-
-  const mdSequence = node.attribs['data-md'];
-  if (mdSequence !== undefined) {
-    const prefix = mdSequence || '-';
-    const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
-    return listLines.map((lineChildren) => ({
-      type: BlockType.Paragraph,
-      children: [
-        { text: `${starOrHyphen ? `${starOrHyphen} ` : `${prefix}. `} ` },
-        ...lineChildren,
-      ],
-    }));
-  }
-
-  if (node.name === 'ol') {
-    return [
-      {
-        type: BlockType.OrderedList,
-        children: listLines.map((lineChildren) => ({
-          type: BlockType.ListItem,
-          children: lineChildren,
-        })),
-      },
-    ];
-  }
-
-  return [
-    {
-      type: BlockType.UnorderedList,
-      children: listLines.map((lineChildren) => ({
-        type: BlockType.ListItem,
-        children: lineChildren,
-      })),
-    },
-  ];
-};
-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 = Number.parseInt(g1AsLevel!, 10);
-
-  const mdSequence = node.attribs['data-md'];
-  if (mdSequence !== undefined) {
-    return {
-      type: BlockType.Paragraph,
-      children: [{ text: `${mdSequence} ` }, ...children],
-    };
-  }
-
-  return {
-    type: BlockType.Heading,
-    level: Math.min(level, 3) as HeadingLevel,
-    children,
-  };
-};
-
-const parseSmallNode = (
-  node: Element,
-  processText: ProcessTextCallback
-): SmallElement | ParagraphElement => {
-  const children = node.children.flatMap((child) => getInlineElement(child, processText));
-  const mdSequence = node.attribs['data-md'];
-
-  if (mdSequence !== undefined) {
-    return {
-      type: BlockType.Paragraph,
-      children: [{ text: `${mdSequence} ` }, ...children],
-    };
-  }
-
-  return {
-    type: BlockType.Small,
-    children,
-  };
-};
-
-const parseHorizontalRuleNode = (node: Element): HorizontalRuleElement | ParagraphElement => {
-  const mdSequence = node.attribs['data-md'];
-
-  if (mdSequence !== undefined) {
-    return {
-      type: BlockType.Paragraph,
-      children: [{ text: mdSequence }],
-    };
-  }
-
-  return {
-    type: BlockType.HorizontalRule,
-    children: [{ text: '' }],
-  };
-};
-
-export const domToEditorInput = (
-  domNodes: ChildNode[],
-  processText: ProcessTextCallback,
-  processLineStartText: ProcessTextCallback
-): Descendant[] => {
-  const children: Descendant[] = [];
-
-  let lineHolder: InlineElement[] = [];
-
-  const appendLine = () => {
-    if (lineHolder.length === 0) return;
-
-    children.push({
-      type: BlockType.Paragraph,
-      children: lineHolder,
-    });
-    lineHolder = [];
-  };
-
-  domNodes.forEach((node) => {
-    if (isText(node)) {
-      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)) {
-      if (node.name === 'br') {
-        lineHolder.push({ text: '' });
-        appendLine();
-        return;
-      }
-
-      if (node.name === 'sub') {
-        appendLine();
-        children.push(parseSmallNode(node, processText));
-        return;
-      }
-
-      if (node.name === 'hr') {
-        appendLine();
-        children.push(parseHorizontalRuleNode(node));
-        return;
-      }
-
-      if (node.name === 'p') {
-        appendLine();
-        children.push({
-          type: BlockType.Paragraph,
-          children: node.children.flatMap((child) => getInlineElement(child, processText)),
-        });
-        return;
-      }
-
-      if (node.name === 'blockquote') {
-        appendLine();
-        children.push(...parseBlockquoteNode(node, processText));
-        return;
-      }
-      if (node.name === 'pre') {
-        appendLine();
-        children.push(...parseCodeBlockNode(node));
-        return;
-      }
-      if (node.name === 'ol' || node.name === 'ul') {
-        appendLine();
-        children.push(...parseListNode(node, processText));
-        return;
-      }
-
-      if (node.name.match(/^h[123456]$/)) {
-        appendLine();
-        children.push(parseHeadingNode(node, processText));
-        return;
-      }
-
-      lineHolder.push(...getInlineElement(node, processText));
-    }
-  });
-  appendLine();
-
-  return children;
-};
-
-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, processText, (lineStartText: string) => {
-    if (!markdown) return lineStartText;
-    return escapeMarkdownBlockSequences(lineStartText, processText);
-  });
-  return editorNodes;
-};
-
-export const plainToEditorInput = (text: string, markdown?: boolean): Descendant[] => {
+export const plainToEditorInput = (text: string): Descendant[] => {
   const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
     const paragraphNode: ParagraphElement = {
       type: BlockType.Paragraph,
       children: [
         {
-          text: markdown
-            ? escapeMarkdownBlockSequences(lineText, escapeMarkdownInlineSequences)
-            : lineText,
+          text: lineText,
         },
       ],
     };
diff --git a/src/app/components/editor/keyboard.ts b/src/app/components/editor/keyboard.ts
index b45e9f9ab..7d9b18d58 100644
--- a/src/app/components/editor/keyboard.ts
+++ b/src/app/components/editor/keyboard.ts
@@ -1,116 +1,87 @@
 import { isKeyHotkey } from 'is-hotkey';
 import type { KeyboardEvent } from 'react';
-import { Editor, Element as SlateElement, Range, Transforms } from 'slate';
-import { isAnyMarkActive, isBlockActive, removeAllMark, toggleBlock, toggleMark } from './utils';
-import { BlockType, MarkType } from './types';
+import { Editor, Range, Transforms } from 'slate';
 
-export const INLINE_HOTKEYS: Record = {
-  'mod+b': MarkType.Bold,
-  'mod+i': MarkType.Italic,
-  'mod+u': MarkType.Underline,
-  'mod+s': MarkType.StrikeThrough,
-  'mod+[': MarkType.Code,
-  'mod+h': MarkType.Spoiler,
+export const INLINE_HOTKEYS: Record = {
+  'mod+b': '**',
+  'mod+i': '*',
+  'mod+u': '__',
+  'mod+s': '~~',
+  'mod+[': '`',
+  'mod+h': '||',
 };
 const INLINE_KEYS = Object.keys(INLINE_HOTKEYS);
 
-export const BLOCK_HOTKEYS: Record = {
-  'mod+7': BlockType.OrderedList,
-  'mod+8': BlockType.UnorderedList,
-  "mod+'": BlockType.BlockQuote,
-  'mod+;': BlockType.CodeBlock,
+export const BLOCK_HOTKEYS: Record = {
+  'mod+7': '1. ',
+  'mod+8': '- ',
+  "mod+'": '> ',
+  'mod+;': '```\n',
 };
 const BLOCK_KEYS = Object.keys(BLOCK_HOTKEYS);
 const isHeading1 = isKeyHotkey('mod+1');
 const isHeading2 = isKeyHotkey('mod+2');
 const isHeading3 = isKeyHotkey('mod+3');
 
+const insertMarkdownInline = (editor: Editor, marker: string) => {
+  if (editor.selection && Range.isExpanded(editor.selection)) {
+    const text = Editor.string(editor, editor.selection);
+    Transforms.insertText(editor, `${marker}${text}${marker}`);
+  } else {
+    Transforms.insertText(editor, `${marker}${marker}`);
+    Transforms.move(editor, { distance: marker.length, reverse: true });
+  }
+};
+
+const insertMarkdownBlock = (editor: Editor, prefix: string) => {
+  if (editor.selection) {
+    const path = editor.selection.anchor.path;
+    const startPoint = Editor.start(editor, path);
+    Transforms.insertText(editor, prefix, { at: startPoint });
+  }
+};
+
 /**
  * @return boolean true if shortcut is toggled.
  */
 export const toggleKeyboardShortcut = (editor: Editor, event: KeyboardEvent): boolean => {
-  if (isKeyHotkey('backspace', event) && editor.selection && Range.isCollapsed(editor.selection)) {
-    const startPoint = Range.start(editor.selection);
-    if (startPoint.offset !== 0) return false;
-
-    const [parentNode, parentPath] = Editor.parent(editor, startPoint);
-    const parentLocation = { at: parentPath };
-    const [previousNode] = Editor.previous(editor, parentLocation) ?? [];
-    const [nextNode] = Editor.next(editor, parentLocation) ?? [];
-
-    if (Editor.isEditor(parentNode)) return false;
-
-    if (parentNode.type === BlockType.Heading) {
-      toggleBlock(editor, BlockType.Paragraph);
-      return true;
-    }
-    if (
-      parentNode.type === BlockType.CodeLine ||
-      parentNode.type === BlockType.QuoteLine ||
-      parentNode.type === BlockType.ListItem
-    ) {
-      // exit formatting only when line block
-      // is first of last of it's parent
-      if (!previousNode || !nextNode) {
-        toggleBlock(editor, BlockType.Paragraph);
-        return true;
-      }
-    }
-    // Unwrap paragraph children to put them
-    // in previous none paragraph element
-    if (SlateElement.isElement(previousNode) && previousNode.type !== BlockType.Paragraph) {
-      Transforms.unwrapNodes(editor, {
-        at: startPoint,
-      });
-    }
-    Editor.deleteBackward(editor);
-    return true;
-  }
-
-  if (isKeyHotkey('mod+e', event) || isKeyHotkey('escape', event)) {
-    if (isAnyMarkActive(editor)) {
-      removeAllMark(editor);
-      return true;
-    }
-
-    if (!isBlockActive(editor, BlockType.Paragraph)) {
-      toggleBlock(editor, BlockType.Paragraph);
-      return true;
-    }
+  if (isKeyHotkey('escape', event)) {
     return false;
   }
 
   const blockToggled = BLOCK_KEYS.find((hotkey) => {
     if (isKeyHotkey(hotkey, event)) {
       event.preventDefault();
-      toggleBlock(editor, BLOCK_HOTKEYS[hotkey]!);
+      insertMarkdownBlock(editor, BLOCK_HOTKEYS[hotkey]!);
       return true;
     }
     return false;
   });
   if (blockToggled) return true;
+
   if (isHeading1(event)) {
-    toggleBlock(editor, BlockType.Heading, { level: 1 });
+    event.preventDefault();
+    insertMarkdownBlock(editor, '# ');
     return true;
   }
   if (isHeading2(event)) {
-    toggleBlock(editor, BlockType.Heading, { level: 2 });
+    event.preventDefault();
+    insertMarkdownBlock(editor, '## ');
     return true;
   }
   if (isHeading3(event)) {
-    toggleBlock(editor, BlockType.Heading, { level: 3 });
+    event.preventDefault();
+    insertMarkdownBlock(editor, '### ');
     return true;
   }
 
-  const inlineToggled = isBlockActive(editor, BlockType.CodeBlock)
-    ? false
-    : INLINE_KEYS.find((hotkey) => {
-        if (isKeyHotkey(hotkey, event)) {
-          event.preventDefault();
-          toggleMark(editor, INLINE_HOTKEYS[hotkey]!);
-          return true;
-        }
-        return false;
-      });
+  const inlineToggled = INLINE_KEYS.find((hotkey) => {
+    if (isKeyHotkey(hotkey, event)) {
+      event.preventDefault();
+      insertMarkdownInline(editor, INLINE_HOTKEYS[hotkey]!);
+      return true;
+    }
+    return false;
+  });
   return !!inlineToggled;
 };
diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts
index d3efcd8dd..e4b6d01d7 100644
--- a/src/app/components/editor/output.ts
+++ b/src/app/components/editor/output.ts
@@ -2,22 +2,14 @@ import type { Descendant, Editor } from 'slate';
 import { Text } from 'slate';
 import type { MatrixClient } from '$types/matrix-sdk';
 import { sanitizeText } from '$utils/sanitize';
-import {
-  parseBlockMD,
-  parseInlineMD,
-  unescapeMarkdownBlockSequences,
-  unescapeMarkdownInlineSequences,
-} from '$plugins/markdown';
-import { findAndReplace } from '$utils/findAndReplace';
+import { markdownToHtml, injectDataMd } from '$plugins/markdown';
 import { sanitizeForRegex } from '$utils/regex';
 import { isUserId } from '$utils/matrix';
 import type { CustomElement } from './slate';
 import { BlockType } from './types';
+import { getMarkdownCodeSpanRanges, isInsideMarkdownCodeSpan } from './utils';
 
 export type OutputOptions = {
-  allowTextFormatting?: boolean;
-  allowInlineMarkdown?: boolean;
-  allowBlockMarkdown?: boolean;
   /**
    * if true it will remove the nickname of the person from the message
    */
@@ -28,48 +20,12 @@ export type OutputOptions = {
   nickNameReplacement?: Map;
 };
 
-const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
-  let string = sanitizeText(node.text);
-  if (opts.allowTextFormatting) {
-    if (node.bold) string = `${string}`;
-    if (node.italic) string = `${string}`;
-    if (node.underline) string = `${string}`;
-    if (node.strikeThrough) string = `${string}`;
-    if (node.code) string = `${string}`;
-    if (node.spoiler) string = `${string}`;
-  }
-
-  if (opts.allowInlineMarkdown && string === sanitizeText(node.text) && !node.code) {
-    string = parseInlineMD(string);
-  }
-
-  return string;
-};
+const textToCustomHtml = (node: Text): string => sanitizeText(node.text);
 
 const elementToCustomHtml = (node: CustomElement, children: string): string => {
   switch (node.type) {
     case BlockType.Paragraph:
       return `${children}
`; - case BlockType.Heading: - return `${children}`; - case BlockType.CodeLine: - return `${children}\n`; - case BlockType.CodeBlock: - return `
${children}
`; - case BlockType.QuoteLine: - return `${children}
`; - case BlockType.BlockQuote: - return `
${children}
`; - case BlockType.ListItem: - return `
  • ${children}

  • `; - case BlockType.OrderedList: - return `
      ${children}
    `; - case BlockType.UnorderedList: - return `
      ${children}
    `; - case BlockType.Small: - return `${children}`; - case BlockType.HorizontalRule: - return `
    `; case BlockType.Mention: { let fragment = node.id; @@ -82,7 +38,7 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { } const matrixTo = `https://matrix.to/#/${fragment}`; - return `${sanitizeText(node.name)}`; + return `${sanitizeText(node.name)}`; } case BlockType.Emoticon: return node.key.startsWith('mxc://') @@ -91,7 +47,7 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { )}" title="${sanitizeText(node.shortcode)}" height="32" />` : sanitizeText(node.key); case BlockType.Link: - return `${children}`; + return `${children}`; case BlockType.Command: return `/${sanitizeText(node.command)}`; default: @@ -99,15 +55,6 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { } }; -const HTML_TAG_REG_G = /<([\w-]+)(?: [^>]*)?(?:(?:\/>)|(?:>.*?<\/\1>))/g; -const ignoreHTMLParseInlineMD = (text: string): string => - findAndReplace( - text, - HTML_TAG_REG_G, - (match) => match[0], - (txt) => parseInlineMD(txt) - ).join(''); - /** * convert slate internal representation to a custom HTML string that can be sent to the server * @param node slate node @@ -120,14 +67,12 @@ export const toMatrixCustomHTML = ( ): string => { let markdownLines = ''; const parseNode = (n: Descendant, index: number, targetNodes: Descendant[]) => { - if (opts.allowBlockMarkdown && 'type' in n && n.type === BlockType.Paragraph) { - let line = toMatrixCustomHTML(n, { - ...opts, - allowInlineMarkdown: false, - allowBlockMarkdown: false, - }) - .replace(/$/, '\n') - .replace(/^(\\*)>/, '$1>'); + if ('type' in n && n.type === BlockType.Paragraph) { + let line = toMatrixCustomHTML(n, opts); + + // Use \n for all paragraphs to prevent extra blank lines from + // accumulating on each edit cycle. + line = line.replace(/$/, '\n').replace(/^(\\*)>/, '$1>'); // strip nicknames if needed if (opts.stripNickname && opts.nickNameReplacement) { @@ -138,21 +83,19 @@ export const toMatrixCustomHTML = ( } markdownLines += line; if (index === targetNodes.length - 1) { - return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD); + const html = markdownToHtml(markdownLines); + return injectDataMd(html); } return ''; } - const parsedMarkdown = parseBlockMD(markdownLines, ignoreHTMLParseInlineMD); + const parsedMarkdown = markdownToHtml(markdownLines); markdownLines = ''; - const isCodeLine = 'type' in n && n.type === BlockType.CodeLine; - if (isCodeLine) return `${parsedMarkdown}${toMatrixCustomHTML(n, {})}`; - - return `${parsedMarkdown}${toMatrixCustomHTML(n, { ...opts, allowBlockMarkdown: false })}`; + return `${parsedMarkdown}${toMatrixCustomHTML(n, opts)}`; }; if (Array.isArray(node)) return node.map((element, index, array) => parseNode(element, index, array)).join(''); - if (Text.isText(node)) return textToCustomHtml(node, opts); + if (Text.isText(node)) return textToCustomHtml(node); const children = node.children .map((element, index, array) => parseNode(element, index, array)) @@ -164,22 +107,6 @@ const elementToPlainText = (node: CustomElement, children: string): string => { switch (node.type) { case BlockType.Paragraph: return `${children}\n`; - case BlockType.Heading: - return `${children}\n`; - case BlockType.CodeLine: - return `${children}\n`; - case BlockType.CodeBlock: - return `${children}\n`; - case BlockType.QuoteLine: - return `| ${children}\n`; - case BlockType.BlockQuote: - return `${children}\n`; - case BlockType.ListItem: - return `- ${children}\n`; - case BlockType.OrderedList: - return `${children}\n`; - case BlockType.UnorderedList: - return `${children}\n`; case BlockType.Mention: return node.id; case BlockType.Emoticon: @@ -188,10 +115,6 @@ const elementToPlainText = (node: CustomElement, children: string): string => { return `[${children}](${node.href})`; case BlockType.Command: return `/${node.command}`; - case BlockType.Small: - return `-# ${children}\n`; - case BlockType.HorizontalRule: - return `\n---\n`; default: return children; } @@ -201,7 +124,7 @@ const SPOILERINPUTREGEX = /\|\|.+?\|\|/g; const LINK_URL = `(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)`; export const LINKINPUTREGEX = new RegExp(`\\(?(${LINK_URL})\\)?`, 'g'); const SPOILEREDLINKINPUTREGEX = new RegExp(`<(${LINK_URL})>`, 'g'); -const MASKEDSPOILEREDLINKINPUTREGEX = new RegExp(`\\[.+\\]\\(${LINK_URL}\\)`, 'g'); +const SPOILEREDLINKDIRECTREGEX = new RegExp(`\\|\\|(${LINK_URL})\\|\\|`, 'g'); /** * convert slate internal representation to a plain text string that can be sent to the server @@ -213,12 +136,11 @@ const MASKEDSPOILEREDLINKINPUTREGEX = new RegExp(`\\[.+\\]\\(${LINK_URL}\\)`, 'g */ export const toPlainText = ( node: Descendant | Descendant[], - isMarkdown: boolean, stripNickname = false, nickNameReplacement?: Map ): string => { if (Array.isArray(node)) - return node.map((n) => toPlainText(n, isMarkdown, stripNickname, nickNameReplacement)).join(''); + return node.map((n) => toPlainText(n, stripNickname, nickNameReplacement)).join(''); if (Text.isText(node)) { let { text } = node; @@ -230,19 +152,41 @@ export const toPlainText = ( const replacement = nickNameReplacement.get(key) ?? ''; text = text.replaceAll(key, replacement); }); - return isMarkdown - ? unescapeMarkdownBlockSequences(text, unescapeMarkdownInlineSequences) - : text; } - return isMarkdown - ? unescapeMarkdownBlockSequences(text, unescapeMarkdownInlineSequences) - : text; + return text; } - const children = node.children.map((n) => toPlainText(n, isMarkdown)).join(''); + const children = node.children + .map((n) => toPlainText(n, stripNickname, nickNameReplacement)) + .join(''); return elementToPlainText(node, children); }; +/** + * Convert slate internal representation to a raw plain text string without any replacements. + * This is used for link extraction to ensure we have the full context for markdown blocks. + */ +export const toRawText = (node: Descendant | Descendant[]): string => { + if (Array.isArray(node)) return node.map(toRawText).join(''); + if (Text.isText(node)) return node.text; + + const children = node.children.map(toRawText).join(''); + switch (node.type) { + case BlockType.Paragraph: + return `${children}\n`; + case BlockType.Link: + return `[${children}](${node.href})`; + case BlockType.Emoticon: + return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key; + case BlockType.Mention: + return node.id; + case BlockType.Command: + return `/${node.command}`; + default: + return children; + } +}; + /** * Check if customHtml is equals to plainText * by replacing `
    ` with `/n` in customHtml @@ -258,9 +202,11 @@ export const customHtmlEqualsPlainText = (customHtml: string, plain: string): bo export const trimCustomHtml = (customHtml: string) => customHtml.replaceAll(/$/g, '').trim(); export const trimCommand = (cmdName: string, str: string) => { - const cmdRegX = new RegExp(`^(\\s+)?(\\/${sanitizeForRegex(cmdName)})([^\\S\n]+)?`); + const escapedCmd = sanitizeForRegex(cmdName); + // Allow optional leading whitespace and/or

    tag for HTML strings + const cmdRegX = new RegExp(`^(?:\\s+)?(?:

    )?(?:\\/${escapedCmd})(?:[^\\S\n]+)?`, 'i'); - const match = new RegExp(cmdRegX).exec(str); + const match = cmdRegX.exec(str); if (!match) return str; return str.slice(match[0].length); }; @@ -294,7 +240,6 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M const parseMentions = (node: Descendant): void => { if (Text.isText(node)) return; - if (node.type === BlockType.CodeBlock) return; if (node.type === BlockType.Mention) { if (node.name === '@room') { @@ -317,56 +262,40 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M }; export const getLinks = (serialized: Descendant | Descendant[]): string[] | undefined => { - let finalList: string[] = []; - let isInsideCodeBlock = false; - const parseLinks = (node: Descendant): void => { - if (Text.isText(node)) { - let { text } = node; - if (text.startsWith('```') && !text.includes(' ')) { - isInsideCodeBlock = !isInsideCodeBlock; - return; - } - if (isInsideCodeBlock) return; - // get a list of all the urls and of the ones that are spoilered, - // truncate the spoilered ones of their <> and then remove the items that are present in both lists - const urlsMatch = text.match(LINKINPUTREGEX); - let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; - urls = urls?.map( - (url) => - (url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) || - (url.startsWith('(') && url.substring(1)) || - (url.endsWith('/)') && url.substring(0, url.length - 1)) || - url - ); - const spoileredUrlsMatch = text.match(SPOILEREDLINKINPUTREGEX); - let spoileredUrls = spoileredUrlsMatch ? [...new Set(spoileredUrlsMatch)] : undefined; - spoileredUrls = spoileredUrls?.map((spoileredUrl) => spoileredUrl.slice(1, -1)); - - const maskedSpoileredUrlsMatch = text.match(MASKEDSPOILEREDLINKINPUTREGEX); - let maskedSpoileredUrls = maskedSpoileredUrlsMatch - ? [...new Set(maskedSpoileredUrlsMatch)] - : undefined; - maskedSpoileredUrls = maskedSpoileredUrls?.map((maskedSpoileredUrl) => - maskedSpoileredUrl?.substring( - maskedSpoileredUrl.indexOf('](') + 2, - maskedSpoileredUrl.lastIndexOf(')') - ) - ); - if (maskedSpoileredUrls) - spoileredUrls = spoileredUrls - ? [...spoileredUrls, ...maskedSpoileredUrls] - : maskedSpoileredUrls; - - spoileredUrls = spoileredUrls?.filter( - (item, index) => spoileredUrls?.indexOf(item) === index - ); - urls = urls?.filter((url) => !spoileredUrls?.includes(url)); - finalList = finalList.concat(urls ?? []); - return; + const text = toRawText(serialized); + const finalList = new Set(); + + // 1. Find all potential URLs + const urlsMatch = text.matchAll(LINKINPUTREGEX); + const spoileredUrlsMatch = [...text.matchAll(SPOILEREDLINKINPUTREGEX)].map((m) => m[1]); + const directSpoileredUrlsMatch = [...text.matchAll(SPOILEREDLINKDIRECTREGEX)].map((m) => m[1]); + const allSpoilered = new Set([...spoileredUrlsMatch, ...directSpoileredUrlsMatch]); + + const codeSpanRanges = getMarkdownCodeSpanRanges(text); + + for (const match of urlsMatch) { + let url = match[1]!; + const fullMatch = match[0]; + const index = match.index; + + // Clean up surrounding parens from markdown [label](url) or (url) + if (fullMatch.startsWith('(') && fullMatch.endsWith(')')) { + url = fullMatch.substring(1, fullMatch.length - 1); + } else if (fullMatch.startsWith('(')) { + url = fullMatch.substring(1); + } else if (fullMatch.endsWith('/)')) { + url = fullMatch.substring(0, fullMatch.length - 1); } - node?.children?.forEach(parseLinks); - }; - if (Array.isArray(serialized)) serialized.map((n) => parseLinks(n)); - else parseLinks(serialized); - return finalList.filter((item, index) => finalList.indexOf(item) === index); + + if (allSpoilered.has(url)) continue; + + // Check if it's inside a code span/block + if (isInsideMarkdownCodeSpan(index, index + fullMatch.length, codeSpanRanges)) { + continue; + } + + finalList.add(url); + } + + return Array.from(finalList); }; diff --git a/src/app/components/editor/slate.d.ts b/src/app/components/editor/slate.d.ts index 05c8b5aec..7cca18623 100644 --- a/src/app/components/editor/slate.d.ts +++ b/src/app/components/editor/slate.d.ts @@ -3,22 +3,13 @@ import type { ReactEditor } from 'slate-react'; import type { HistoryEditor } from 'slate-history'; import type { BlockType } from './types'; -export type HeadingLevel = 1 | 2 | 3; - export type Editor = BaseEditor & HistoryEditor & ReactEditor; export type Text = { text: string; }; -export type FormattedText = Text & { - bold?: boolean; - italic?: boolean; - underline?: boolean; - strikeThrough?: boolean; - code?: boolean; - spoiler?: boolean; -}; +export type FormattedText = Text; export type LinkElement = { type: BlockType.Link; @@ -47,77 +38,29 @@ export type CommandElement = { children: Text[]; }; -export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement | CommandElement; +export type InlineElement = + | FormattedText + | LinkElement + | MentionElement + | EmoticonElement + | CommandElement; export type ParagraphElement = { type: BlockType.Paragraph; children: InlineElement[]; }; -export type HeadingElement = { - type: BlockType.Heading; - level: HeadingLevel; - children: InlineElement[]; -}; -export type CodeLineElement = { - type: BlockType.CodeLine; - children: Text[]; -}; -export type CodeBlockElement = { - type: BlockType.CodeBlock; - children: CodeLineElement[]; -}; -export type QuoteLineElement = { - type: BlockType.QuoteLine; - children: InlineElement[]; -}; -export type BlockQuoteElement = { - type: BlockType.BlockQuote; - children: QuoteLineElement[]; -}; -export type ListItemElement = { - type: BlockType.ListItem; - children: InlineElement[]; -}; -export type OrderedListElement = { - type: BlockType.OrderedList; - children: ListItemElement[]; -}; -export type UnorderedListElement = { - type: BlockType.UnorderedList; - children: ListItemElement[]; -}; - -export type SmallElement = { - type: BlockType.Small; - children: InlineElement[]; -}; - -export type HorizontalRuleElement = { - type: BlockType.HorizontalRule; - children: Text[]; -}; export type CustomElement = | LinkElement | MentionElement | EmoticonElement | CommandElement - | ParagraphElement - | HeadingElement - | CodeLineElement - | CodeBlockElement - | QuoteLineElement - | BlockQuoteElement - | ListItemElement - | OrderedListElement - | UnorderedListElement - | SmallElement - | HorizontalRuleElement; + | ParagraphElement; declare module 'slate' { interface CustomTypes { Editor: Editor; Element: CustomElement; - Text: FormattedText & Text; + Text: FormattedText; } } diff --git a/src/app/components/editor/types.ts b/src/app/components/editor/types.ts index 52115a3f4..a1cac110d 100644 --- a/src/app/components/editor/types.ts +++ b/src/app/components/editor/types.ts @@ -1,26 +1,7 @@ -export enum MarkType { - Bold = 'bold', - Italic = 'italic', - Underline = 'underline', - StrikeThrough = 'strikeThrough', - Code = 'code', - Spoiler = 'spoiler', -} - export enum BlockType { Paragraph = 'paragraph', - Heading = 'heading', - CodeLine = 'code-line', - CodeBlock = 'code-block', - QuoteLine = 'quote-line', - BlockQuote = 'block-quote', - ListItem = 'list-item', - OrderedList = 'ordered-list', - UnorderedList = 'unordered-list', Mention = 'mention', Emoticon = 'emoticon', Link = 'link', Command = 'command', - Small = 'sub', - HorizontalRule = 'hr', } diff --git a/src/app/components/editor/utils.ts b/src/app/components/editor/utils.ts index 254d7a5b7..03adabd90 100644 --- a/src/app/components/editor/utils.ts +++ b/src/app/components/editor/utils.ts @@ -1,154 +1,14 @@ import type { BasePoint, BaseRange } from 'slate'; import { Editor, Element, Point, Range, Text, Transforms } from 'slate'; -import { BlockType, MarkType } from './types'; +import { BlockType } from './types'; import type { CommandElement, EmoticonElement, FormattedText, - HeadingLevel, LinkElement, MentionElement, } from './slate'; -const ALL_MARK_TYPE: MarkType[] = [ - MarkType.Bold, - MarkType.Code, - MarkType.Italic, - MarkType.Spoiler, - MarkType.StrikeThrough, - MarkType.Underline, -]; - -export const isMarkActive = (editor: Editor, format: MarkType) => { - const marks = Editor.marks(editor); - return marks ? marks[format] === true : false; -}; - -export const isAnyMarkActive = (editor: Editor) => { - const marks = Editor.marks(editor); - return marks && !!ALL_MARK_TYPE.find((type) => marks[type] === true); -}; - -export const toggleMark = (editor: Editor, format: MarkType) => { - const isActive = isMarkActive(editor, format); - - if (isActive) { - Editor.removeMark(editor, format); - } else { - Editor.addMark(editor, format, true); - } -}; - -export const removeAllMark = (editor: Editor) => { - ALL_MARK_TYPE.forEach((mark) => { - if (isMarkActive(editor, mark)) Editor.removeMark(editor, mark); - }); -}; - -export const isBlockActive = (editor: Editor, format: BlockType) => { - const [match] = Editor.nodes(editor, { - match: (node) => Element.isElement(node) && node.type === format, - }); - - return !!match; -}; - -export const headingLevel = (editor: Editor): HeadingLevel | undefined => { - const [nodeEntry] = Editor.nodes(editor, { - match: (node) => Element.isElement(node) && node.type === BlockType.Heading, - }); - const [node] = nodeEntry ?? []; - if (!node) return undefined; - if ('level' in node) return node.level; - return undefined; -}; - -type BlockOption = { level: HeadingLevel }; -const NESTED_BLOCK = new Set([ - BlockType.OrderedList, - BlockType.UnorderedList, - BlockType.BlockQuote, - BlockType.CodeBlock, -]); - -export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => { - Transforms.collapse(editor, { - edge: 'end', - }); - const isActive = isBlockActive(editor, format); - - Transforms.unwrapNodes(editor, { - match: (node) => Element.isElement(node) && NESTED_BLOCK.has(node.type), - split: true, - }); - - if (isActive) { - Transforms.setNodes(editor, { - type: BlockType.Paragraph, - }); - return; - } - - if (format === BlockType.OrderedList || format === BlockType.UnorderedList) { - Transforms.setNodes(editor, { - type: BlockType.ListItem, - }); - const block = { - type: format, - children: [], - }; - Transforms.wrapNodes(editor, block); - return; - } - if (format === BlockType.CodeBlock) { - Transforms.setNodes(editor, { - type: BlockType.CodeLine, - }); - const block = { - type: format, - children: [], - }; - Transforms.wrapNodes(editor, block); - return; - } - - if (format === BlockType.BlockQuote) { - Transforms.setNodes(editor, { - type: BlockType.QuoteLine, - }); - const block = { - type: format, - children: [], - }; - Transforms.wrapNodes(editor, block); - return; - } - - if (format === BlockType.Heading) { - Transforms.setNodes(editor, { - type: format, - level: option?.level ?? 1, - }); - } - - if (format === BlockType.HorizontalRule) { - Transforms.insertNodes(editor, { - type: BlockType.HorizontalRule, - children: [{ text: '' }], - }); - return; - } - - if (format === BlockType.Small) { - Transforms.setNodes(editor, { type: BlockType.Small }); - return; - } - - Transforms.setNodes(editor, { - type: format, - }); -}; - export const resetEditor = (editor: Editor) => { Transforms.delete(editor, { at: { @@ -157,8 +17,7 @@ export const resetEditor = (editor: Editor) => { }, }); - toggleBlock(editor, BlockType.Paragraph); - removeAllMark(editor); + Transforms.setNodes(editor, { type: BlockType.Paragraph }); }; export const resetEditorHistory = (editor: Editor) => { @@ -286,3 +145,35 @@ export const getBeginCommand = (editor: Editor): string | undefined => { return secondInline.command; return undefined; }; + +export const getMarkdownCodeSpanRanges = (text: string): [number, number][] => { + const ranges: [number, number][] = []; + let openRun: { start: number; length: number } | undefined; + + for (let index = 0; index < text.length; index += 1) { + if (text[index] === '`') { + let runEnd = index; + while (runEnd < text.length && text[runEnd] === '`') { + runEnd += 1; + } + + const runLength = runEnd - index; + if (!openRun) { + openRun = { start: index, length: runLength }; + } else if (openRun.length === runLength) { + ranges.push([openRun.start, runEnd]); + openRun = undefined; + } + + index = runEnd - 1; + } + } + + return ranges; +}; + +export const isInsideMarkdownCodeSpan = ( + start: number, + end: number, + codeSpanRanges: [number, number][] +): boolean => codeSpanRanges.some(([rangeStart, rangeEnd]) => start > rangeStart && end < rangeEnd); diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index d94c92564..66430ac02 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -93,6 +93,57 @@ type MTextProps = { renderBundledPreviews?: (bundles: IPreviewUrlResponse[]) => ReactNode; style?: CSSProperties; }; + +const getUrlsFromContent = ( + content: Record, + renderUrlsPreview?: (urls: string[]) => ReactNode +): { urls?: string[]; bundleContent?: BundleContent[] } => { + const body = typeof content.body === 'string' ? content.body : ''; + const customBody = + typeof content.formatted_body === 'string' ? content.formatted_body : undefined; + const trimmedBody = trimReplyFromBody(body); + + const urlsMatch = trimmedBody.match(LINKINPUTREGEX); + let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + urls = urls?.map( + (url) => + (url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) || + (url.startsWith('(') && url.substring(1)) || + (url.endsWith('/)') && url.substring(0, url.length - 1)) || + url + ); + + if (urls && customBody) { + // Filter out URLs that only appear inside or

     tags in the formatted body
    +    const safeHtml = customBody
    +      .replace(/]*>.*?<\/pre>/gs, '')
    +      .replace(/]*>.*?<\/code>/gs, '');
    +    const safeText = safeHtml.replace(/<[^>]*>/g, '');
    +    const safeUrlsMatch = safeText.match(LINKINPUTREGEX);
    +    let safeUrls = safeUrlsMatch ? [...new Set(safeUrlsMatch)] : [];
    +    safeUrls = safeUrls.map(
    +      (url) =>
    +        (url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) ||
    +        (url.startsWith('(') && url.substring(1)) ||
    +        (url.endsWith('/)') && url.substring(0, url.length - 1)) ||
    +        url
    +    );
    +    const safeUrlsSet = new Set(safeUrls);
    +    urls = urls.filter((url) => safeUrlsSet.has(url));
    +  }
    +
    +  let bundleContent = content['com.beeper.linkpreviews'] as BundleContent[];
    +  try {
    +    bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url));
    +    if (renderUrlsPreview && bundleContent)
    +      urls = bundleContent.map((bundle) => bundle.matched_url);
    +  } catch {
    +    urls = [];
    +  }
    +
    +  return { urls, bundleContent };
    +};
    +
     export function MText({
       edited,
       content,
    @@ -106,11 +157,15 @@ export function MText({
       const body = typeof content.body === 'string' ? content.body : '';
       const customBody =
         typeof content.formatted_body === 'string' ? content.formatted_body : undefined;
    +  const cleanedMessage = useMemo(
    +    () => customBody?.replace(/
  • (

    <\/p>)?<\/li>/gi, '


  • '), + [customBody] + ); const trimmedBody = useMemo(() => trimReplyFromBody(body), [body]); const unwrappedForwardedContent = useMemo( - () => unwrapForwardedContent(customBody ?? body), - [customBody, body] + () => unwrapForwardedContent(cleanedMessage ?? customBody ?? body), + [cleanedMessage, customBody, body] ); const isForwarded = useMemo(() => { @@ -122,14 +177,15 @@ export function MText({ * For the unwrapping of per-message profile fallbacks, we look for tags with the data-mx-profile-fallback attribute */ const unwrappedPerMessageProfileMessage = useMemo( - () => customBody?.replace(/]*data-mx-profile-fallback[^>]*>(.*?):\s*<\/strong>/i, ''), - [customBody] + () => + cleanedMessage?.replace(/]*data-mx-profile-fallback[^>]*>(.*?):\s*<\/strong>/i, ''), + [cleanedMessage] ); const isJumbo = useMemo(() => { if (!trimmedBody || trimmedBody.length >= 500) return false; if ( - (unwrappedPerMessageProfileMessage ?? customBody)?.match( + (unwrappedPerMessageProfileMessage ?? cleanedMessage ?? customBody)?.match( /^(]*data-mx-emoticon[^>]*\/>){1,20}$/i ) ) @@ -142,35 +198,17 @@ export function MText({ } return true; - }, [unwrappedPerMessageProfileMessage, trimmedBody, customBody]); + }, [unwrappedPerMessageProfileMessage, cleanedMessage, trimmedBody, customBody]); if (!body && !customBody) return ; - let bundleContent: BundleContent[] | undefined; - const urlsMatch = trimmedBody.match(LINKINPUTREGEX); - let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; - urls = urls?.map( - (url) => - (url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) || - (url.startsWith('(') && url.substring(1)) || - (url.endsWith('/)') && url.substring(0, url.length - 1)) || - url - ); - bundleContent = content['com.beeper.linkpreviews'] as BundleContent[]; - //small "fix" for if someone sends malformed objects (ie not arrays of objects) - try { - bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url)); - if (renderUrlsPreview && bundleContent) - urls = bundleContent.map((bundle) => bundle.matched_url); - } catch { - urls = []; - } + const { urls, bundleContent } = getUrlsFromContent(content, renderUrlsPreview); if ((content['com.beeper.per_message_profile'] as PerMessageProfileBeeperFormat)?.has_fallback) { // unwrap per-message profile fallback if present return ( @@ -203,13 +241,13 @@ export function MText({ return ( <> {renderBody({ body: trimmedBody, - customBody: typeof customBody === 'string' ? customBody : undefined, + customBody: typeof cleanedMessage === 'string' ? cleanedMessage : undefined, })} {edited && } @@ -239,6 +277,13 @@ export function MEmote({ renderBundledPreviews, }: MEmoteProps) { const { body, formatted_body: customBody } = content; + const cleanedMessage = useMemo( + () => + typeof customBody === 'string' + ? customBody.replace(/
  • (

    <\/p>)?<\/li>/gi, '


  • ') + : undefined, + [customBody] + ); const [jumboEmojiSize] = useSetting(settingsAtom, 'jumboEmojiSize'); if (typeof body !== 'string') { @@ -247,37 +292,19 @@ export function MEmote({ const trimmedBody = trimReplyFromBody(body); const isJumbo = JUMBO_EMOJI_REG.test(trimmedBody); - let bundleContent: BundleContent[] | undefined; - const urlsMatch = trimmedBody.match(LINKINPUTREGEX); - let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; - urls = urls?.map( - (url) => - (url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) || - (url.startsWith('(') && url.substring(1)) || - (url.endsWith('/)') && url.substring(0, url.length - 1)) || - url - ); - bundleContent = content['com.beeper.linkpreviews'] as BundleContent[]; - //small "fix" for if someone sends malformed objects (ie not arrays of objects) - try { - bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url)); - if (renderUrlsPreview && bundleContent) - urls = bundleContent.map((bundle) => bundle.matched_url); - } catch { - urls = []; - } + const { urls, bundleContent } = getUrlsFromContent(content, renderUrlsPreview); return ( <> {`${displayName} `} {renderBody({ body: trimmedBody, - customBody: typeof customBody === 'string' ? customBody : undefined, + customBody: typeof cleanedMessage === 'string' ? cleanedMessage : undefined, })} {edited && } @@ -305,6 +332,13 @@ export function MNotice({ renderBundledPreviews, }: MNoticeProps) { const { body, formatted_body: customBody } = content; + const cleanedMessage = useMemo( + () => + typeof customBody === 'string' + ? customBody.replace(/
  • (

    <\/p>)?<\/li>/gi, '


  • ') + : undefined, + [customBody] + ); const [jumboEmojiSize] = useSetting(settingsAtom, 'jumboEmojiSize'); if (typeof body !== 'string') { @@ -313,36 +347,18 @@ export function MNotice({ const trimmedBody = trimReplyFromBody(body); const isJumbo = JUMBO_EMOJI_REG.test(trimmedBody); - let bundleContent: BundleContent[] | undefined; - const urlsMatch = trimmedBody.match(LINKINPUTREGEX); - let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; - urls = urls?.map( - (url) => - (url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) || - (url.startsWith('(') && url.substring(1)) || - (url.endsWith('/)') && url.substring(0, url.length - 1)) || - url - ); - bundleContent = content['com.beeper.linkpreviews'] as BundleContent[]; - //small "fix" for if someone sends malformed objects (ie not arrays of objects) - try { - bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url)); - if (renderUrlsPreview && bundleContent) - urls = bundleContent.map((bundle) => bundle.matched_url); - } catch { - urls = []; - } + const { urls, bundleContent } = getUrlsFromContent(content, renderUrlsPreview); return ( <> {renderBody({ body: trimmedBody, - customBody: typeof customBody === 'string' ? customBody : undefined, + customBody: typeof cleanedMessage === 'string' ? cleanedMessage : undefined, })} {edited && } diff --git a/src/app/components/upload-card/UploadDescriptionEditor.tsx b/src/app/components/upload-card/UploadDescriptionEditor.tsx index 8c98a3050..8a4f72ff3 100644 --- a/src/app/components/upload-card/UploadDescriptionEditor.tsx +++ b/src/app/components/upload-card/UploadDescriptionEditor.tsx @@ -2,7 +2,7 @@ import type { KeyboardEventHandler } from 'react'; import { useCallback, useEffect, useState, useRef } from 'react'; import type { Room } from '$types/matrix-sdk'; import type { RectCords } from 'folds'; -import { Box, Chip, Icon, IconButton, Icons, Line, PopOut, Spinner, Text, config } from 'folds'; +import { Box, Chip, Icon, IconButton, Icons, PopOut, Spinner, Text, config } from 'folds'; import { Editor, Transforms } from 'slate'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; @@ -11,11 +11,9 @@ import { AutocompletePrefix, CustomEditor, EmoticonAutocomplete, - Toolbar, createEmoticonElement, getAutocompleteQuery, getPrevWorldRange, - htmlToEditorInput, plainToEditorInput, moveCursor, toMatrixCustomHTML, @@ -23,6 +21,7 @@ import { trimCustomHtml, useEditor, } from '$components/editor'; +import { htmlToMarkdown } from '$plugins/markdown'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { UseStateProvider } from '$components/UseStateProvider'; @@ -47,8 +46,6 @@ export function DescriptionEditor({ }: Readonly) { const editor = useEditor(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); - const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); - const [toolbar, setToolbar] = useState(false); const [autocompleteQuery, setAutocompleteQuery] = useState>(); @@ -56,18 +53,12 @@ export function DescriptionEditor({ const prevValue = useRef(value); const initialized = useRef(false); const handleSave = useCallback(() => { - const plainText = toPlainText(editor.children, isMarkdown).trim(); + const plainText = toPlainText(editor.children).trim(); - const customHtml = trimCustomHtml( - toMatrixCustomHTML(editor.children, { - allowTextFormatting: true, - allowBlockMarkdown: isMarkdown, - allowInlineMarkdown: isMarkdown, - }) - ); + const customHtml = trimCustomHtml(toMatrixCustomHTML(editor.children, {})); onSave(plainText, customHtml || plainText); - }, [editor, isMarkdown, onSave]); + }, [editor, onSave]); useEffect(() => { const valueChanged = prevValue.current !== value; @@ -88,17 +79,16 @@ export function DescriptionEditor({ const safeValue = typeof normalizedValue === 'string' ? normalizedValue : ''; const incomingPlainText = toPlainText( - htmlToEditorInput(safeValue, isMarkdown), - isMarkdown + plainToEditorInput(safeValue.includes('<') ? htmlToMarkdown(safeValue) : safeValue) ).trim(); - const currentPlainText = toPlainText(editor.children, isMarkdown).trim(); + const currentPlainText = toPlainText(editor.children).trim(); if (currentPlainText === incomingPlainText && initialized.current) return; const isLikelyHtml = safeValue.includes('<') || safeValue.includes('>'); const initialValue = isLikelyHtml - ? htmlToEditorInput(safeValue, isMarkdown) - : plainToEditorInput(safeValue, isMarkdown); + ? plainToEditorInput(htmlToMarkdown(safeValue)) + : plainToEditorInput(safeValue); editor.children = initialValue; Editor.normalize(editor, { force: true }); @@ -106,7 +96,7 @@ export function DescriptionEditor({ initialized.current = true; } - }, [value, editor, isMarkdown]); + }, [value, editor]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { @@ -203,14 +193,6 @@ export function DescriptionEditor({ - setToolbar(!toolbar)} - > - - {(anchor: RectCords | undefined, setAnchor) => ( - {toolbar && ( - - - - - )} } /> diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index db21b3544..3eb901dc6 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -22,7 +22,6 @@ import { Icon, IconButton, Icons, - Line, Menu, MenuItem, Overlay, @@ -46,7 +45,6 @@ import { resetEditor, RoomMentionAutocomplete, toMatrixCustomHTML, - Toolbar, toPlainText, trimCustomHtml, UserMentionAutocomplete, @@ -60,6 +58,7 @@ import { ANYWHERE_AUTOCOMPLETE_PREFIXES, BEGINNING_AUTOCOMPLETE_PREFIXES, getLinks, + BlockType, } from '$components/editor'; import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board'; import { UseStateProvider } from '$components/UseStateProvider'; @@ -234,7 +233,7 @@ export const RoomInput = forwardRef( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); - const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [mentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); @@ -285,7 +284,6 @@ export const RoomInput = forwardRef( const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents); - const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [showAudioRecorder, setShowAudioRecorder] = useState(false); const audioRecorderRef = useRef(null); const micHoldStartRef = useRef(0); @@ -506,7 +504,7 @@ export const RoomInput = forwardRef( }; const handleSendUpload = async (uploads: UploadSuccess[]) => { - const plainText = toPlainText(editor.children, isMarkdown).trim(); + const plainText = toPlainText(editor.children).trim(); const contentsPromises = uploads.map(async (upload) => { const fileItem = selectedFiles.find((f) => f.file === upload.file); @@ -719,8 +717,26 @@ export const RoomInput = forwardRef( * the plain text we will send */ let serializedChildren = editor.children; + if (commandName) { + // Strip the empty text node and command node from the beginning of the first paragraph + const firstPara = serializedChildren[0]; + if ( + firstPara && + 'type' in firstPara && + firstPara.type === BlockType.Paragraph && + firstPara.children.length >= 2 + ) { + serializedChildren = [ + { + ...firstPara, + children: firstPara.children.slice(2), + }, + ...serializedChildren.slice(1), + ]; + } + } const outgoingTransformContext = { - isMarkdown, + isMarkdown: true, settingsLinkBaseUrl, }; @@ -729,16 +745,13 @@ export const RoomInput = forwardRef( serializedChildren = transform.apply(serializedChildren, outgoingTransformContext); }); - let plainText = toPlainText(serializedChildren, isMarkdown, true, nicknameReplacement).trim(); + let plainText = toPlainText(serializedChildren, true, nicknameReplacement).trim(); /** * the html we will send */ let customHtml = trimCustomHtml( toMatrixCustomHTML(serializedChildren, { - allowTextFormatting: true, - allowBlockMarkdown: isMarkdown, - allowInlineMarkdown: isMarkdown, stripNickname: true, nickNameReplacement: nicknameReplacement, }) @@ -852,7 +865,8 @@ export const RoomInput = forwardRef( } else { // we don't have a formatted body, but we need one content.format = 'org.matrix.custom.html'; - content.formatted_body = `${htmlPrefix}${plainText}`; + const escapedBody = sanitizeText(plainText).replaceAll('\n', '
    '); + content.formatted_body = `${htmlPrefix}${escapedBody}`; } } } @@ -966,7 +980,6 @@ export const RoomInput = forwardRef( replyEvent, mx, roomId, - isMarkdown, canSendReaction, pkCompatEnable, replyDraft, @@ -1460,17 +1473,6 @@ export const RoomInput = forwardRef( )} - setToolbar(!toolbar)} - > - - {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( ( } - bottom={ - toolbar && ( -
    - - -
    - ) - } /> {showSchedulePicker && ( ( const mx = useMatrixClient(); const editor = useEditor(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); - const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); - const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); - const [toolbar, setToolbar] = useState(globalToolbar); const isComposing = useComposingCheck(); const [autocompleteQuery, setAutocompleteQuery] = @@ -168,14 +164,8 @@ export const MessageEditor = as<'div', MessageEditorProps>( const [saveState, save] = useAsyncCallback( useCallback(async () => { const oldContent = mEvent.getContent(); - let plainText = toPlainText(editor.children, isMarkdown).trim(); - let customHtml = trimCustomHtml( - toMatrixCustomHTML(editor.children, { - allowTextFormatting: true, - allowBlockMarkdown: isMarkdown, - allowInlineMarkdown: isMarkdown, - }) - ); + let plainText = toPlainText(editor.children).trim(); + let customHtml = trimCustomHtml(toMatrixCustomHTML(editor.children, {})); const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody(); @@ -256,7 +246,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( const links = getLinks(editor.children); - if (!customHtmlEqualsPlainText(customHtml, plainText)) { + if (pmpDisplayname || !customHtmlEqualsPlainText(customHtml, plainText)) { newContent.format = 'org.matrix.custom.html'; newContent.formatted_body = customHtml; contentBody.format = 'org.matrix.custom.html'; @@ -295,7 +285,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( content['m.new_content']['com.beeper.linkpreviews'] = content['com.beeper.linkpreviews']; return mx.sendMessage(roomId, content as RoomMessageEventContent); - }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody, room]) + }, [mx, editor, roomId, mEvent, getPrevBodyAndFormattedBody, room]) ); const handleSave = useCallback(() => { @@ -357,10 +347,9 @@ export const MessageEditor = as<'div', MessageEditorProps>( useEffect(() => { const [body, customHtml] = getPrevBodyAndFormattedBody(); - const initialValue = - typeof customHtml === 'string' - ? htmlToEditorInput(customHtml, isMarkdown) - : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown); + const initialValue = plainToEditorInput( + customHtml ? htmlToMarkdown(customHtml) : typeof body === 'string' ? body : '' + ); Transforms.select(editor, { anchor: Editor.start(editor, []), @@ -369,7 +358,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( editor.insertFragment(initialValue); if (!mobileOrTablet()) ReactEditor.focus(editor); - }, [editor, getPrevBodyAndFormattedBody, isMarkdown]); + }, [editor, getPrevBodyAndFormattedBody]); useEffect(() => { if (saveState.status === AsyncStatus.Success) { @@ -504,14 +493,6 @@ export const MessageEditor = as<'div', MessageEditorProps>( - setToolbar(!toolbar)} - > - - {(anchor: RectCords | undefined, setAnchor) => ( ( - {toolbar && ( -
    - - -
    - )} } /> diff --git a/src/app/features/room/outgoingMessageTransforms.ts b/src/app/features/room/outgoingMessageTransforms.ts index 497c5f62d..a1b41f061 100644 --- a/src/app/features/room/outgoingMessageTransforms.ts +++ b/src/app/features/room/outgoingMessageTransforms.ts @@ -1,11 +1,7 @@ import type { Descendant } from 'slate'; -import { - hasSettingsLinksToRewriteInDescendants, - rewriteSettingsLinksInDescendants, -} from './settingsLinkMessage'; +import { hasSettingsLinksToRewrite, rewriteSettingsLinks } from './settingsLinkMessage'; export type OutgoingMessageTransformContext = { - isMarkdown: boolean; settingsLinkBaseUrl: string; }; @@ -16,13 +12,8 @@ export type OutgoingMessageTransform = { export const outgoingMessageTransforms: OutgoingMessageTransform[] = [ { - apply: (children, context) => - rewriteSettingsLinksInDescendants(children, context.settingsLinkBaseUrl, context.isMarkdown), + apply: (children, context) => rewriteSettingsLinks(children, context.settingsLinkBaseUrl), shouldApply: (children, context) => - hasSettingsLinksToRewriteInDescendants( - children, - context.settingsLinkBaseUrl, - context.isMarkdown - ), + hasSettingsLinksToRewrite(children, context.settingsLinkBaseUrl), }, ]; diff --git a/src/app/features/room/settingsLinkMessage.test.ts b/src/app/features/room/settingsLinkMessage.test.ts index 44f2b3157..ca96f717e 100644 --- a/src/app/features/room/settingsLinkMessage.test.ts +++ b/src/app/features/room/settingsLinkMessage.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest'; import { toMatrixCustomHTML, toPlainText, trimCustomHtml } from '$components/editor/output'; import { BlockType } from '$components/editor/types'; -import { - hasSettingsLinksToRewriteInDescendants, - rewriteSettingsLinksInDescendants, -} from './settingsLinkMessage'; +import { hasSettingsLinksToRewrite, rewriteSettingsLinks } from './settingsLinkMessage'; const settingsUrl = 'https://app.example/settings/account?focus=display-name&moe.sable.client.action=settings'; @@ -16,7 +13,7 @@ const invalidSettingsUrl = describe('settingsLinkMessage', () => { it('detects bare settings links that need outgoing rewriting', () => { expect( - hasSettingsLinksToRewriteInDescendants( + hasSettingsLinksToRewrite( [ { type: BlockType.Paragraph, @@ -29,7 +26,7 @@ describe('settingsLinkMessage', () => { }); it('rewrites bare settings links into message-friendly labels before serialization', () => { - const rewritten = rewriteSettingsLinksInDescendants( + const rewritten = rewriteSettingsLinks( [ { type: BlockType.Paragraph, @@ -39,22 +36,16 @@ describe('settingsLinkMessage', () => { 'https://app.example' ); - expect(toPlainText(rewritten, false).trim()).toBe( + expect(toPlainText(rewritten).trim()).toBe( `[Settings > Account > Display Name](${settingsUrl})` ); - expect( - trimCustomHtml( - toMatrixCustomHTML(rewritten, { - allowTextFormatting: true, - allowBlockMarkdown: false, - allowInlineMarkdown: false, - }) - ) - ).toBe(`Settings > Account > Display Name`); + expect(trimCustomHtml(toMatrixCustomHTML(rewritten, {}))).toContain( + `Settings > Account > Display Name` + ); }); it('rewrites same-base settings links with extra query params', () => { - const rewritten = rewriteSettingsLinksInDescendants( + const rewritten = rewriteSettingsLinks( [ { type: BlockType.Paragraph, @@ -64,13 +55,13 @@ describe('settingsLinkMessage', () => { 'https://app.example' ); - expect(toPlainText(rewritten, false).trim()).toBe( + expect(toPlainText(rewritten).trim()).toBe( `[Settings > Account > Display Name](${settingsUrlWithExtraParam})` ); }); it('does not rewrite settings links that are already in markdown link syntax', () => { - const rewritten = rewriteSettingsLinksInDescendants( + const rewritten = rewriteSettingsLinks( [ { type: BlockType.Paragraph, @@ -80,107 +71,69 @@ describe('settingsLinkMessage', () => { 'https://app.example' ); - expect(toPlainText(rewritten, true).trim()).toBe(`[Display Name](${settingsUrl})`); - }); - - it('does not rewrite settings links inside code blocks', () => { - const rewritten = rewriteSettingsLinksInDescendants( - [ - { - type: BlockType.CodeBlock, - children: [ - { - type: BlockType.CodeLine, - children: [{ text: settingsUrl }], - }, - ], - }, - ], - 'https://app.example' - ); - - expect(toPlainText(rewritten, false).trim()).toBe(settingsUrl); - expect( - trimCustomHtml( - toMatrixCustomHTML(rewritten, { - allowTextFormatting: true, - allowBlockMarkdown: false, - allowInlineMarkdown: false, - }) - ) - ).not.toContain(' { expect( - hasSettingsLinksToRewriteInDescendants( + hasSettingsLinksToRewrite( [ { type: BlockType.Paragraph, children: [{ text: `\`${settingsUrl}\`` }], }, ], - 'https://app.example', - true + 'https://app.example' ) ).toBe(false); - const rewritten = rewriteSettingsLinksInDescendants( + const rewritten = rewriteSettingsLinks( [ { type: BlockType.Paragraph, children: [{ text: `\`${settingsUrl}\`` }], }, ], - 'https://app.example', - true + 'https://app.example' ); - expect(toPlainText(rewritten, true).trim()).toBe(`\`${settingsUrl}\``); - expect( - trimCustomHtml( - toMatrixCustomHTML(rewritten, { - allowTextFormatting: true, - allowBlockMarkdown: false, - allowInlineMarkdown: true, - }) - ) - ).not.toContain('Settings > Account > Display Name'); + expect(toPlainText(rewritten).trim()).toBe(`\`${settingsUrl}\``); + expect(trimCustomHtml(toMatrixCustomHTML(rewritten, {}))).not.toContain( + 'Settings > Account > Display Name' + ); }); it('does not rewrite settings links inside markdown autolinks', () => { - const rewritten = rewriteSettingsLinksInDescendants( + const rewritten = rewriteSettingsLinks( [ { type: BlockType.Paragraph, children: [{ text: `<${settingsUrl}>` }], }, ], - 'https://app.example', - true + 'https://app.example' ); - expect(toPlainText(rewritten, true).trim()).toBe(settingsUrl); + expect(toPlainText(rewritten).trim()).toBe(settingsUrl); }); it('does not rewrite settings links inside literal html text', () => { - const rewritten = rewriteSettingsLinksInDescendants( + const rewritten = rewriteSettingsLinks( [ { type: BlockType.Paragraph, children: [{ text: `Settings` }], }, ], - 'https://app.example', - true + 'https://app.example' ); - expect(toPlainText(rewritten, true).trim()).toBe(`Settings`); + expect(toPlainText(rewritten).trim()).toBe(`Settings`); }); it('does not rewrite settings links with unknown focus ids', () => { expect( - hasSettingsLinksToRewriteInDescendants( + hasSettingsLinksToRewrite( [ { type: BlockType.Paragraph, @@ -191,7 +144,7 @@ describe('settingsLinkMessage', () => { ) ).toBe(false); - const rewritten = rewriteSettingsLinksInDescendants( + const rewritten = rewriteSettingsLinks( [ { type: BlockType.Paragraph, @@ -201,12 +154,12 @@ describe('settingsLinkMessage', () => { 'https://app.example' ); - expect(toPlainText(rewritten, false).trim()).toBe(invalidSettingsUrl); + expect(toPlainText(rewritten).trim()).toBe(invalidSettingsUrl); }); it('rewrites plain same-base hash-router settings links when given the runtime app base', () => { const hashRouterSettingsUrl = 'https://app.example/#/app/settings/account?focus=display-name'; - const rewritten = rewriteSettingsLinksInDescendants( + const rewritten = rewriteSettingsLinks( [ { type: BlockType.Paragraph, @@ -216,7 +169,7 @@ describe('settingsLinkMessage', () => { 'https://app.example/#/app' ); - expect(toPlainText(rewritten, false).trim()).toBe( + expect(toPlainText(rewritten).trim()).toBe( `[Settings > Account > Display Name](${hashRouterSettingsUrl})` ); }); diff --git a/src/app/features/room/settingsLinkMessage.ts b/src/app/features/room/settingsLinkMessage.ts index 90fa4e1d6..e3f25523e 100644 --- a/src/app/features/room/settingsLinkMessage.ts +++ b/src/app/features/room/settingsLinkMessage.ts @@ -1,20 +1,13 @@ import { find as findLinks } from 'linkifyjs'; import type { Descendant } from 'slate'; import { Text } from 'slate'; -import type { - BlockQuoteElement, - FormattedText, - HeadingElement, - InlineElement, - ListItemElement, - OrderedListElement, - ParagraphElement, - QuoteLineElement, - SmallElement, - UnorderedListElement, -} from '$components/editor/slate'; +import type { FormattedText, InlineElement, ParagraphElement } from '$components/editor/slate'; import { BlockType } from '$components/editor/types'; -import { createLinkElement } from '$components/editor/utils'; +import { + createLinkElement, + getMarkdownCodeSpanRanges, + isInsideMarkdownCodeSpan, +} from '$components/editor/utils'; import { getSettingsLinkLabel, parseSettingsLink } from '$features/settings/settingsLink'; type RewritableSettingsLinkMatch = { @@ -27,38 +20,6 @@ type RewritableSettingsLinkMatch = { const isMarkdownSettingsLink = (text: string, start: number, end: number): boolean => text.slice(0, start).endsWith('](') && text.slice(end).startsWith(')'); -const getMarkdownCodeSpanRanges = (text: string): [number, number][] => { - const ranges: [number, number][] = []; - let openRun: { start: number; length: number } | undefined; - - for (let index = 0; index < text.length; index += 1) { - if (text[index] === '`') { - let runEnd = index; - while (runEnd < text.length && text[runEnd] === '`') { - runEnd += 1; - } - - const runLength = runEnd - index; - if (!openRun) { - openRun = { start: index, length: runLength }; - } else if (openRun.length === runLength) { - ranges.push([openRun.start, runEnd]); - openRun = undefined; - } - - index = runEnd - 1; - } - } - - return ranges; -}; - -const isInsideMarkdownCodeSpan = ( - start: number, - end: number, - codeSpanRanges: [number, number][] -): boolean => codeSpanRanges.some(([rangeStart, rangeEnd]) => start > rangeStart && end < rangeEnd); - const isMarkdownAutolink = (text: string, start: number, end: number): boolean => text[start - 1] === '<' && text[end] === '>'; @@ -76,32 +37,26 @@ const isProtectedMarkdownContext = ( text: string, start: number, end: number, - isMarkdown: boolean, codeSpanRanges: [number, number][] ): boolean => isMarkdownSettingsLink(text, start, end) || - (isMarkdown && - (isInsideMarkdownCodeSpan(start, end, codeSpanRanges) || - isMarkdownAutolink(text, start, end) || - isInsideHtmlTag(text, start))); + isInsideMarkdownCodeSpan(start, end, codeSpanRanges) || + isMarkdownAutolink(text, start, end) || + isInsideHtmlTag(text, start); const getRewritableSettingsLinkMatches = ( text: string, - baseUrl: string, - isMarkdown: boolean + baseUrl: string ): RewritableSettingsLinkMatch[] => { const matches = findLinks(text, 'url'); if (matches.length === 0) return []; - const codeSpanRanges = isMarkdown ? getMarkdownCodeSpanRanges(text) : []; + const codeSpanRanges = getMarkdownCodeSpanRanges(text); return matches.flatMap((match) => { const href = match.value; const settingsLink = parseSettingsLink(baseUrl, href); - if ( - !settingsLink || - isProtectedMarkdownContext(text, match.start, match.end, isMarkdown, codeSpanRanges) - ) { + if (!settingsLink || isProtectedMarkdownContext(text, match.start, match.end, codeSpanRanges)) { return []; } @@ -118,13 +73,11 @@ const getRewritableSettingsLinkMatches = ( const hasRewritableSettingsLinksInInlineChildren = ( children: InlineElement[], - baseUrl: string, - isMarkdown: boolean + baseUrl: string ): boolean => children.some( (child) => - Text.isText(child) && - getRewritableSettingsLinkMatches(child.text, baseUrl, isMarkdown).length > 0 + Text.isText(child) && getRewritableSettingsLinkMatches(child.text, baseUrl).length > 0 ); const createTextSegment = (node: FormattedText, text: string): FormattedText => ({ @@ -132,12 +85,8 @@ const createTextSegment = (node: FormattedText, text: string): FormattedText => text, }); -const rewriteInlineText = ( - node: FormattedText, - baseUrl: string, - isMarkdown: boolean -): InlineElement[] => { - const matches = getRewritableSettingsLinkMatches(node.text, baseUrl, isMarkdown); +const rewriteInlineText = (node: FormattedText, baseUrl: string): InlineElement[] => { + const matches = getRewritableSettingsLinkMatches(node.text, baseUrl); if (matches.length === 0) return [node]; const rewritten: InlineElement[] = []; @@ -161,125 +110,40 @@ const rewriteInlineText = ( return rewritten.filter((child) => !Text.isText(child) || child.text.length > 0); }; -const rewriteInlineChildren = ( - children: InlineElement[], - baseUrl: string, - isMarkdown: boolean -): InlineElement[] => - children.flatMap((child) => - Text.isText(child) ? rewriteInlineText(child, baseUrl, isMarkdown) : [child] - ); - -const rewriteInlineContainer = < - T extends ParagraphElement | HeadingElement | QuoteLineElement | ListItemElement | SmallElement, ->( - node: T, - baseUrl: string, - isMarkdown: boolean -): T => ({ - ...node, - children: rewriteInlineChildren(node.children, baseUrl, isMarkdown), -}); - -const rewriteBlockQuote = ( - node: BlockQuoteElement, - baseUrl: string, - isMarkdown: boolean -): BlockQuoteElement => ({ - ...node, - children: node.children.map((child) => rewriteInlineContainer(child, baseUrl, isMarkdown)), -}); - -const rewriteOrderedList = ( - node: OrderedListElement, - baseUrl: string, - isMarkdown: boolean -): OrderedListElement => ({ - ...node, - children: node.children.map((child) => rewriteInlineContainer(child, baseUrl, isMarkdown)), -}); +const rewriteInlineChildren = (children: InlineElement[], baseUrl: string): InlineElement[] => + children.flatMap((child) => (Text.isText(child) ? rewriteInlineText(child, baseUrl) : [child])); -const rewriteUnorderedList = ( - node: UnorderedListElement, - baseUrl: string, - isMarkdown: boolean -): UnorderedListElement => ({ +const rewriteInlineContainer = (node: ParagraphElement, baseUrl: string): ParagraphElement => ({ ...node, - children: node.children.map((child) => rewriteInlineContainer(child, baseUrl, isMarkdown)), + children: rewriteInlineChildren(node.children, baseUrl), }); -const hasSettingsLinksToRewriteInNode = ( - node: Descendant, - baseUrl: string, - isMarkdown: boolean -): boolean => { +const hasSettingsLinksToRewriteInNode = (node: Descendant, baseUrl: string): boolean => { if (Text.isText(node)) { - return getRewritableSettingsLinkMatches(node.text, baseUrl, isMarkdown).length > 0; + return getRewritableSettingsLinkMatches(node.text, baseUrl).length > 0; } switch (node.type) { case BlockType.Paragraph: - case BlockType.Heading: - case BlockType.QuoteLine: - case BlockType.ListItem: - case BlockType.Small: - return hasRewritableSettingsLinksInInlineChildren(node.children, baseUrl, isMarkdown); - case BlockType.BlockQuote: - case BlockType.OrderedList: - case BlockType.UnorderedList: - return node.children.some((child) => - hasSettingsLinksToRewriteInNode(child, baseUrl, isMarkdown) - ); - case BlockType.CodeBlock: - case BlockType.CodeLine: - case BlockType.HorizontalRule: - case BlockType.Link: - case BlockType.Mention: - case BlockType.Emoticon: - case BlockType.Command: - return false; + return hasRewritableSettingsLinksInInlineChildren(node.children, baseUrl); default: return false; } }; -const rewriteNode = (node: Descendant, baseUrl: string, isMarkdown: boolean): Descendant => { +const rewriteNode = (node: Descendant, baseUrl: string): Descendant => { if (Text.isText(node)) return node; switch (node.type) { case BlockType.Paragraph: - case BlockType.Heading: - case BlockType.QuoteLine: - case BlockType.ListItem: - case BlockType.Small: - return rewriteInlineContainer(node, baseUrl, isMarkdown); - case BlockType.BlockQuote: - return rewriteBlockQuote(node, baseUrl, isMarkdown); - case BlockType.OrderedList: - return rewriteOrderedList(node, baseUrl, isMarkdown); - case BlockType.UnorderedList: - return rewriteUnorderedList(node, baseUrl, isMarkdown); - case BlockType.CodeBlock: - case BlockType.CodeLine: - case BlockType.HorizontalRule: - case BlockType.Link: - case BlockType.Mention: - case BlockType.Emoticon: - case BlockType.Command: - return node; + return rewriteInlineContainer(node, baseUrl); default: return node; } }; -export const rewriteSettingsLinksInDescendants = ( - children: Descendant[], - baseUrl: string, - isMarkdown = false -): Descendant[] => children.map((child) => rewriteNode(child, baseUrl, isMarkdown)); +export const hasSettingsLinksToRewrite = (nodes: Descendant[], baseUrl: string): boolean => + nodes.some((node) => hasSettingsLinksToRewriteInNode(node, baseUrl)); -export const hasSettingsLinksToRewriteInDescendants = ( - children: Descendant[], - baseUrl: string, - isMarkdown = false -): boolean => children.some((child) => hasSettingsLinksToRewriteInNode(child, baseUrl, isMarkdown)); +export const rewriteSettingsLinks = (nodes: Descendant[], baseUrl: string): Descendant[] => + nodes.map((node) => rewriteNode(node, baseUrl)); diff --git a/src/app/features/settings/account/BioEditor.tsx b/src/app/features/settings/account/BioEditor.tsx index 05f8c2f76..7d58172fb 100644 --- a/src/app/features/settings/account/BioEditor.tsx +++ b/src/app/features/settings/account/BioEditor.tsx @@ -2,7 +2,7 @@ import type { KeyboardEventHandler } from 'react'; import { useCallback, useEffect, useState, useRef } from 'react'; import type { Room } from '$types/matrix-sdk'; import type { RectCords } from 'folds'; -import { Box, Chip, Icon, IconButton, Icons, Line, PopOut, Spinner, Text, config } from 'folds'; +import { Box, Chip, Icon, IconButton, Icons, PopOut, Spinner, Text, config } from 'folds'; import { Editor, Transforms } from 'slate'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; @@ -11,11 +11,9 @@ import { AutocompletePrefix, CustomEditor, EmoticonAutocomplete, - Toolbar, createEmoticonElement, getAutocompleteQuery, getPrevWorldRange, - htmlToEditorInput, plainToEditorInput, moveCursor, toMatrixCustomHTML, @@ -23,6 +21,7 @@ import { trimCustomHtml, useEditor, } from '$components/editor'; +import { htmlToMarkdown } from '$plugins/markdown'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { UseStateProvider } from '$components/UseStateProvider'; @@ -43,9 +42,6 @@ const BIO_LIMIT = 1024; export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditorProps) { const editor = useEditor(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); - const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); - const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); - const [toolbar, setToolbar] = useState(globalToolbar); const [autocompleteQuery, setAutocompleteQuery] = useState>(); @@ -56,25 +52,19 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor const initialized = useRef(false); const updateStats = useCallback(() => { - const plainText = toPlainText(editor.children, isMarkdown).trim(); + const plainText = toPlainText(editor.children).trim(); setCharCount(plainText.length); - }, [editor, isMarkdown]); + }, [editor]); const handleSave = useCallback(() => { - const plainText = toPlainText(editor.children, isMarkdown).trim(); + const plainText = toPlainText(editor.children).trim(); if (plainText.length > BIO_LIMIT) return; - const customHtml = trimCustomHtml( - toMatrixCustomHTML(editor.children, { - allowTextFormatting: true, - allowBlockMarkdown: isMarkdown, - allowInlineMarkdown: isMarkdown, - }) - ); + const customHtml = trimCustomHtml(toMatrixCustomHTML(editor.children, {})); onSave(customHtml || plainText, plainText); setHasChanged(false); - }, [editor, isMarkdown, onSave]); + }, [editor, onSave]); useEffect(() => { const valueChanged = prevValue.current !== value; @@ -95,17 +85,16 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor const safeValue = typeof normalizedValue === 'string' ? normalizedValue : ''; const incomingPlainText = toPlainText( - htmlToEditorInput(safeValue, isMarkdown), - isMarkdown + plainToEditorInput(safeValue.includes('<') ? htmlToMarkdown(safeValue) : safeValue) ).trim(); - const currentPlainText = toPlainText(editor.children, isMarkdown).trim(); + const currentPlainText = toPlainText(editor.children).trim(); if (currentPlainText === incomingPlainText && initialized.current) return; const isLikelyHtml = safeValue.includes('<') || safeValue.includes('>'); const initialValue = isLikelyHtml - ? htmlToEditorInput(safeValue, isMarkdown) - : plainToEditorInput(safeValue, isMarkdown); + ? plainToEditorInput(htmlToMarkdown(safeValue)) + : plainToEditorInput(safeValue); editor.children = initialValue; Editor.normalize(editor, { force: true }); @@ -115,7 +104,7 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor setHasChanged(false); updateStats(); } - }, [value, editor, isMarkdown, updateStats]); + }, [value, editor, updateStats]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { @@ -211,14 +200,6 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor - setToolbar(!toolbar)} - > - - {(anchor: RectCords | undefined, setAnchor) => ( - {toolbar && ( - - - - - )} } /> diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 061953d14..16cc92772 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -415,7 +415,6 @@ function DateAndTime() { function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); - const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -444,13 +443,6 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { } /> - - } - /> - { it('reads account data on mount and applies it to the atom', () => { const remoteContent = { v: SETTINGS_SYNC_VERSION, - settings: { isMarkdown: false }, + settings: { twitterEmoji: false }, }; mockMx.getAccountData.mockReturnValueOnce({ getContent: () => remoteContent, }); - const store = makeStore({ settingsSyncEnabled: true, isMarkdown: true }); + const store = makeStore({ settingsSyncEnabled: true, twitterEmoji: true }); renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); - expect(store.get(settingsAtom).isMarkdown).toBe(false); + expect(store.get(settingsAtom).twitterEmoji).toBe(false); }); it('sets lastSynced after loading from account data on mount', () => { - const remoteContent = { v: SETTINGS_SYNC_VERSION, settings: { isMarkdown: false } }; + const remoteContent = { v: SETTINGS_SYNC_VERSION, settings: { twitterEmoji: false } }; mockMx.getAccountData.mockReturnValueOnce({ getContent: () => remoteContent }); const store = makeStore({ settingsSyncEnabled: true }); @@ -230,7 +230,7 @@ describe('useSettingsSyncEffect — echo-token loop prevention', () => { }); it('skips re-applying an event that echoes our own upload token', async () => { - const store = makeStore({ settingsSyncEnabled: true, isMarkdown: true }); + const store = makeStore({ settingsSyncEnabled: true, twitterEmoji: true }); renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); // Trigger the upload. @@ -247,15 +247,15 @@ describe('useSettingsSyncEffect — echo-token loop prevention', () => { const echoEvent = makeSableSettingsEvent({ v: SETTINGS_SYNC_VERSION, synctoken: echoToken, - settings: { isMarkdown: false }, // different — must be ignored + settings: { twitterEmoji: false }, // different — must be ignored }); act(() => { callbackHolder.current?.(echoEvent); }); - // isMarkdown should stay true (echo was ignored). - expect(store.get(settingsAtom).isMarkdown).toBe(true); + // twitterEmoji should stay true (echo was ignored). + expect(store.get(settingsAtom).twitterEmoji).toBe(true); }); it('marks sync status as idle and updates lastSynced when own echo arrives', async () => { @@ -290,12 +290,12 @@ describe('useSettingsSyncEffect — echo-token loop prevention', () => { }); it('applies an event from another device (different or absent echo token)', () => { - const store = makeStore({ settingsSyncEnabled: true, isMarkdown: true }); + const store = makeStore({ settingsSyncEnabled: true, twitterEmoji: true }); renderHook(() => useSettingsSyncEffect(), { wrapper: makeWrapper(store) }); const remoteEvent = makeSableSettingsEvent({ v: SETTINGS_SYNC_VERSION, - settings: { isMarkdown: false }, + settings: { twitterEmoji: false }, // No synctoken — definitely from another device. }); @@ -303,6 +303,6 @@ describe('useSettingsSyncEffect — echo-token loop prevention', () => { callbackHolder.current?.(remoteEvent); }); - expect(store.get(settingsAtom).isMarkdown).toBe(false); + expect(store.get(settingsAtom).twitterEmoji).toBe(false); }); }); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 8282e5daf..b9dcf30c6 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -745,7 +745,6 @@ function SentryTagsFeature() { Sentry.setTag('message_layout', String(settings.messageLayout)); Sentry.setTag('message_spacing', settings.messageSpacing); Sentry.setTag('twitter_emoji', String(settings.twitterEmoji)); - Sentry.setTag('is_markdown', String(settings.isMarkdown)); Sentry.setTag('page_zoom', String(settings.pageZoom)); if (settings.themeId) Sentry.setTag('theme_id', settings.themeId); // Additional high-value tags for bug reproduction diff --git a/src/app/plugins/markdown/bidirectional.test.ts b/src/app/plugins/markdown/bidirectional.test.ts new file mode 100644 index 000000000..b7aa44d5b --- /dev/null +++ b/src/app/plugins/markdown/bidirectional.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { markdownToHtml } from './markdownToHtml'; +import { htmlToMarkdown } from './htmlToMarkdown'; +import { injectDataMd } from './injectDataMd'; + +describe('bidirectional round-trip', () => { + it('round-trips headings', () => { + const markdown = '## Hello World'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('## Hello World'); + }); + + it('round-trips bold text', () => { + const markdown = '**bold text**'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('**bold text**'); + }); + + it('round-trips italic text', () => { + const markdown = '*italic text*'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('*italic text*'); + }); + + it('round-trips inline code', () => { + const markdown = '`inline code`'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('`inline code`'); + }); + + it('round-trips code blocks', () => { + const markdown = '```rust\nfn main() {}\n```'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('```rust'); + expect(result).toContain('fn main()'); + }); + + it('round-trips blockquotes', () => { + const markdown = '> Quote text'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('> Quote text'); + }); + + it('round-trips unordered lists', () => { + const markdown = '- Item 1\n- Item 2'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('- Item 1'); + expect(result).toContain('- Item 2'); + }); + + it('round-trips ordered lists', () => { + const markdown = '1. First\n2. Second'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + // Note: marked normalizes ordered lists to start at 1, but we increment for output + expect(result).toContain('1. First'); + expect(result).toContain('2. Second'); + }); + + it('round-trips spoiler syntax', () => { + const markdown = '||hidden message||'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('||hidden message||'); + }); + + it('round-trips inline math', () => { + const markdown = '$E = mc^2$'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('$E = mc^2$'); + }); + + it('round-trips block math', () => { + const markdown = '$$x + y$$'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('$$x + y$$'); + }); + + it('does NOT parse k. as a list', () => { + const markdown = 'k.Hello world'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + // Should NOT contain any list markers + expect(result).not.toContain('
  • '); + expect(result).not.toContain('
      '); + expect(result).not.toContain('
        '); + expect(result).toContain('k.'); + }); + + it('round-trips img[data-mx-emoticon] tags', () => { + const html = ':blobcat:'; + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain(' { - if (text === '') return text; - let result: string | undefined; - - if (!result) result = runBlockRule(text, CodeBlockRule, parseBlockMD, parseInline); - if (!result) result = runBlockRule(text, HorizontalRuleRule, 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, SmallRule, 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 deleted file mode 100644 index 370527a3d..000000000 --- a/src/app/plugins/markdown/block/rules.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { 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; - if (!g1 || !g2) return ''; - 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 = '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}
        `; - }, -}; - -const HR_MD = '---'; -const HR_REG = /^--- *(?:\n|$)/m; -export const HorizontalRuleRule: BlockMDRule = { - match: (text) => text.match(HR_REG), - html: () => `
        `, -}; - -const SMALL_MD = '-#'; -const SMALL_REG = /^-# +(.+)\n?/m; -export const SmallRule: BlockMDRule = { - match: (text) => text.match(SMALL_REG), - html: (match, parseInline) => { - const [, g1] = match; - if (!g1) return ''; - const content = parseInline ? parseInline(g1) : g1; - return `${content}`; - }, -}; - -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 deleted file mode 100644 index ecaee874d..000000000 --- a/src/app/plugins/markdown/block/runner.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { replaceMatch } from '$plugins/markdown/internal'; -import type { 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 deleted file mode 100644 index dc272de83..000000000 --- a/src/app/plugins/markdown/block/type.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { MatchResult, MatchRule } from '$plugins/markdown/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/extensions/matrix-emoticon.ts b/src/app/plugins/markdown/extensions/matrix-emoticon.ts new file mode 100644 index 000000000..a340358b5 --- /dev/null +++ b/src/app/plugins/markdown/extensions/matrix-emoticon.ts @@ -0,0 +1,68 @@ +import type { TokenizerExtension, RendererExtension } from 'marked'; + +/** + * Validates that a URL is a proper mxc:// URI. + * Returns true if valid, false otherwise. + */ +function validateMxcUrlInternal(url: string): boolean { + if (!url.startsWith('mxc://')) return false; + + try { + const parsed = new URL(url); + if (parsed.protocol !== 'mxc:') return false; + if (!parsed.host) return false; + if (!parsed.pathname || parsed.pathname.length < 1) return false; + if (parsed.username || parsed.password || parsed.search || parsed.hash) return false; + return true; + } catch { + return false; + } +} + +// Extension to preserve img[data-mx-emoticon] tags through markdown pipeline +export const matrixEmoticonExtension = { + name: 'emoticon', + level: 'inline', + start(src: string) { + return src.indexOf('data-mx-emoticon'); + }, + tokenizer(src: string) { + const rule = /^]*data-mx-emoticon[^>]*(?:\/>|>(?=\s*<\/img>))/i; + const match = rule.exec(src); + if (!match) return undefined; + + const rawHtml = match[0]; + + const srcMatch = /src\s*=\s*["']([^"']*)["']/i.exec(rawHtml); + if (!srcMatch) return undefined; + + const srcValue = srcMatch[1]; + if (!srcValue || !validateMxcUrlInternal(srcValue)) return undefined; + + return { + type: 'emoticon', + raw: rawHtml, + html: rawHtml, + }; + }, + renderer(token) { + return token.html; + }, +} satisfies TokenizerExtension & RendererExtension; + +// Preprocessor to strip invalid emoticon img tags before marked processing +export function preprocessEmoticon(markdown: string): string { + // Remove img[data-mx-emoticon] tags with invalid src URLs + return markdown.replace(/]*data-mx-emoticon[^>]*>/gi, (tag) => { + const srcMatch = /src\s*=\s*["']([^"']*)["']/i.exec(tag); + if (!srcMatch) return tag; // Keep if no src attribute + const srcValue = srcMatch[1]; + if (!srcValue || !validateMxcUrlInternal(srcValue)) return ''; // Remove invalid + return tag; // Keep valid + }); +} + +// Standalone validation function for use by htmlToMarkdown +export function validateMxcUrl(url: string): boolean { + return validateMxcUrlInternal(url); +} diff --git a/src/app/plugins/markdown/extensions/matrix-math.ts b/src/app/plugins/markdown/extensions/matrix-math.ts new file mode 100644 index 000000000..590d84b55 --- /dev/null +++ b/src/app/plugins/markdown/extensions/matrix-math.ts @@ -0,0 +1,55 @@ +import type { TokenizerExtension, RendererExtension } from 'marked'; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// Inline math: $...$ +export const matrixMathExtension = { + name: 'math', + level: 'inline', + start(src: string) { + return src.indexOf('$'); + }, + tokenizer(src: string) { + const match = /^\$([^$]+)\$/.exec(src); + if (match) { + return { + type: 'math', + raw: match[0], + latex: match[1], + }; + } + return undefined; + }, + renderer(token) { + return `${token.latex}`; + }, +} satisfies TokenizerExtension & RendererExtension; + +// Block math: $$...$$ +export const matrixMathBlockExtension = { + name: 'mathBlock', + level: 'block', + start(src: string) { + return src.indexOf('$$'); + }, + tokenizer(src: string) { + const match = /^\$\$([^$]+)\$\$\n?/.exec(src); + if (match) { + return { + type: 'mathBlock', + raw: match[0], + latex: match[1]?.trim() ?? '', + }; + } + return undefined; + }, + renderer(token) { + return `
        ${token.latex}
        `; + }, +} satisfies TokenizerExtension & RendererExtension; diff --git a/src/app/plugins/markdown/extensions/matrix-spoiler.ts b/src/app/plugins/markdown/extensions/matrix-spoiler.ts new file mode 100644 index 000000000..30bc2cba4 --- /dev/null +++ b/src/app/plugins/markdown/extensions/matrix-spoiler.ts @@ -0,0 +1,37 @@ +import type { TokenizerExtension, RendererExtension, Tokens } from 'marked'; + +// Extend marked's lexer to handle ||spoiler|| syntax +export const matrixSpoilerExtension = { + name: 'spoiler', + level: 'inline', + start(src: string) { + return src.indexOf('||'); + }, + tokenizer( + this: { lexer: { inlineTokens: (t: string, tokens: Tokens.Generic[]) => void } }, + src: string + ) { + // Only match if || at the very start of the remaining text + if (!src.startsWith('||')) return undefined; + const rule = /^\|\|(.+?)\|\|/; + const match = rule.exec(src); + if (match) { + const token = { + type: 'spoiler', + raw: match[0], + text: match[1], + tokens: [] as Tokens.Generic[], + }; + this.lexer.inlineTokens(token.text!, token.tokens); + return token; + } + return undefined; + }, + renderer( + this: { parser: { parseInline: (tokens: Tokens.Generic[]) => string } }, + token: Tokens.Generic + ) { + const tokens = (token as { tokens: Tokens.Generic[] }).tokens || []; + return `${this.parser.parseInline(tokens)}`; + }, +} satisfies TokenizerExtension & RendererExtension; diff --git a/src/app/plugins/markdown/extensions/matrix-subscript.ts b/src/app/plugins/markdown/extensions/matrix-subscript.ts new file mode 100644 index 000000000..922f9377d --- /dev/null +++ b/src/app/plugins/markdown/extensions/matrix-subscript.ts @@ -0,0 +1,34 @@ +import type { TokenizerExtension, RendererExtension, Tokens } from 'marked'; + +// Subscript extension: -# text (Matrix spec small/sub tag) +export const matrixSubscriptExtension = { + name: 'subscript', + level: 'block', + start(src: string) { + return src.indexOf('-#'); + }, + tokenizer( + this: { lexer: { inlineTokens: (t: string, tokens: Tokens.Generic[]) => void } }, + src: string + ) { + const match = /^-# +(.+)/.exec(src); + if (match) { + const token = { + type: 'subscript', + raw: match[0], + text: match[1], + tokens: [] as Tokens.Generic[], + }; + this.lexer.inlineTokens(token.text!, token.tokens); + return token; + } + return undefined; + }, + renderer( + this: { parser: { parseInline: (tokens: Tokens.Generic[]) => string } }, + token: Tokens.Generic + ) { + const tokens = (token as { tokens: Tokens.Generic[] }).tokens || []; + return `${this.parser.parseInline(tokens)}`; + }, +} satisfies TokenizerExtension & RendererExtension; diff --git a/src/app/plugins/markdown/extensions/matrix.test.ts b/src/app/plugins/markdown/extensions/matrix.test.ts new file mode 100644 index 000000000..ed1b254a8 --- /dev/null +++ b/src/app/plugins/markdown/extensions/matrix.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { marked } from 'marked'; +import { matrixSpoilerExtension } from './matrix-spoiler'; +import { matrixMathExtension, matrixMathBlockExtension } from './matrix-math'; +import { matrixSubscriptExtension } from './matrix-subscript'; + +function parse(input: string): string { + const processor = marked.use({ + extensions: [ + matrixSpoilerExtension, + matrixMathExtension, + matrixMathBlockExtension, + matrixSubscriptExtension, + ], + }); + return processor.parse(input) as string; +} + +describe('matrixSpoilerExtension', () => { + it('parses ||spoiler|| syntax', () => { + expect(parse('Hello ||spoiler|| world')).toContain('data-mx-spoiler'); + expect(parse('Hello ||spoiler|| world')).toContain('>spoiler<'); + }); + + it('does not parse text without spoiler markers', () => { + expect(parse('No spoilers here')).not.toContain('data-mx-spoiler'); + }); + + it('parses ||hidden|| without surrounding text', () => { + const result = parse('||hidden||'); + expect(result).toContain('data-mx-spoiler'); + expect(result).toContain('>hidden<'); + }); +}); + +describe('matrixMathExtension (inline)', () => { + it('parses inline $...$ syntax', () => { + expect(parse('$E = mc^2$')).toContain('data-mx-maths'); + expect(parse('$E = mc^2$')).toContain('E = mc^2'); + }); + + it('parses inline math within text', () => { + const result = parse('Math: $x$ value'); + expect(result).toContain('data-mx-maths'); + expect(result).toContain('>x<'); + }); + + it('does not parse unmatched $', () => { + expect(parse('No $ math here')).not.toContain('data-mx-maths'); + }); +}); + +describe('matrixMathBlockExtension (block)', () => { + it('parses block $$...$$ syntax', () => { + const result = parse('$$\\frac{a}{b}$$'); + expect(result).toContain('data-mx-maths'); + expect(result).toContain(' { + const result = parse('$x$'); + expect(result).not.toContain(' { + it('parses -# syntax', () => { + const result = parse('-# subscript text'); + expect(result).toContain(' { + it('converts headings', () => { + expect(htmlToMarkdown('

        Hello

        ')).toContain('# Hello'); + expect(htmlToMarkdown('

        World

        ')).toContain('## World'); + }); + + it('converts bold text', () => { + expect(htmlToMarkdown('bold')).toContain('**bold**'); + expect(htmlToMarkdown('bold')).toContain('**bold**'); + }); + + it('converts italic text', () => { + expect(htmlToMarkdown('italic')).toContain('*italic*'); + expect(htmlToMarkdown('italic')).toContain('*italic*'); + }); + + it('converts strikethrough', () => { + expect(htmlToMarkdown('deleted')).toContain('~~deleted~~'); + expect(htmlToMarkdown('deleted')).toContain('~~deleted~~'); + }); + + it('converts inline code', () => { + expect(htmlToMarkdown('code')).toContain('`code`'); + }); + + it('converts code blocks', () => { + expect(htmlToMarkdown('
        fn main() {}
        ')).toContain( + '```rust' + ); + }); + + it('converts links', () => { + expect(htmlToMarkdown('link')).toContain( + '[link](https://example.com)' + ); + }); + + it('converts spoiler spans', () => { + expect(htmlToMarkdown('hidden')).toContain('||hidden||'); + }); + + it('converts inline math spans', () => { + expect(htmlToMarkdown('E = mc^2')).toContain( + '$E = mc^2$' + ); + }); + + it('converts block math divs', () => { + expect(htmlToMarkdown('
        frac
        ')).toContain( + '$$\\frac{a}{b}$$' + ); + }); + + it('converts blockquotes', () => { + const result = htmlToMarkdown('
        Quote text
        '); + expect(result).toContain('>'); + expect(result).toContain('Quote text'); + }); + + it('converts unordered lists', () => { + const result = htmlToMarkdown('
        • Item 1
        • Item 2
        '); + expect(result).toContain('-'); + expect(result).toContain('Item 1'); + }); + + it('converts ordered lists', () => { + const result = htmlToMarkdown('
        1. Item 1
        2. Item 2
        '); + expect(result).toContain('1.'); + expect(result).toContain('Item 1'); + }); + + it('preserves data-md attributes for round-trip', () => { + const result = htmlToMarkdown('bold'); + expect(result).toContain('**bold**'); + }); + + it('escapes markdown special characters in text', () => { + const result = htmlToMarkdown('

        Hello *world*

        '); + expect(result).toContain('\\*'); + }); +}); diff --git a/src/app/plugins/markdown/htmlToMarkdown.ts b/src/app/plugins/markdown/htmlToMarkdown.ts new file mode 100644 index 000000000..f6835a1dc --- /dev/null +++ b/src/app/plugins/markdown/htmlToMarkdown.ts @@ -0,0 +1,348 @@ +import parse from 'html-dom-parser'; +import type { ChildNode, Element } from 'domhandler'; +import { isText, isTag } from 'domhandler'; +import { validateMxcUrl } from './extensions/matrix-emoticon'; +import { escapeMarkdownInlineSequences } from './utils'; + +/** + * Converts Matrix-compatible HTML back to markdown for round-trip editing. + * Preserves original markdown syntax via data-md attributes and converts + * Matrix-specific elements (spoilers, math) back to their markdown equivalents. + * + * @param html - Input HTML string (should be pre-sanitized) + * @returns Markdown string for editor editing + */ +export function htmlToMarkdown(html: string): string { + const domNodes = parse(html); + return processNodes(domNodes).trim(); +} + +function isBlockTag(node: ChildNode | undefined): boolean { + if (!node || !isTag(node)) return false; + const blocks = [ + 'p', + 'div', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'blockquote', + 'pre', + 'hr', + 'table', + 'details', + 'summary', + ]; + return blocks.includes(node.name.toLowerCase()); +} + +function processNodes(nodes: ChildNode[]): string { + return nodes + .filter((n, i) => { + if (isText(n) && /^\s*$/.test(n.data)) { + const prev = nodes[i - 1]; + const next = nodes[i + 1]; + // Ignore whitespace between block tags or at the edges + const isBetweenBlocks = (!prev || isBlockTag(prev)) && (!next || isBlockTag(next)); + if (isBetweenBlocks) return false; + } + return true; + }) + .map((n) => processNode(n)) + .join(''); +} + +function processNode(node: ChildNode, listDepth: number = 0): string { + if (isText(node)) { + return escapeMarkdownInlineSequences(node.data); + } + + if (!isTag(node)) { + return ''; + } + + const tag = node.name.toLowerCase(); + + // Handle Matrix-specific attributes + if (tag === 'span') { + if (node.attribs['data-mx-spoiler'] !== undefined) { + return processSpoiler(node); + } + if (node.attribs['data-mx-maths'] !== undefined) { + return processMath(node, 'inline'); + } + if (node.attribs['data-md'] !== undefined) { + return processInlineMarkdown(node); + } + if ( + node.attribs['data-mx-color'] !== undefined || + node.attribs['data-mx-bg-color'] !== undefined + ) { + return reconstructTag(node); + } + } + + if (tag === 'div') { + if (node.attribs['data-mx-maths'] !== undefined) { + return processMath(node, 'block'); + } + } + + // Handle block elements + switch (tag) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + return processHeading(node, tag); + + case 'p': + return processParagraph(node); + + case 'strong': + case 'b': + return processInlineWrapper(node, '**'); + + case 'em': + case 'i': + return processInlineWrapper(node, '*'); + + case 'u': + return processInlineWrapper(node, '_'); + + case 's': + case 'del': + return processInlineWrapper(node, '~~'); + + case 'code': + return processCode(node); + + case 'pre': + return processPre(node); + + case 'blockquote': + return processBlockquote(node); + + case 'ul': + return processUnorderedList(node, listDepth); + + case 'ol': + return processOrderedList(node, listDepth); + + case 'li': + return processListItem(node); + + case 'a': + return processLink(node); + + case 'br': + return '\n'; + + case 'hr': + return '---\n'; + + case 'sub': + return processSubscript(node); + + case 'img': + return processImage(node); + + default: + return processInlineElements(node); + } +} +function reconstructTag(node: Element): string { + const content = processInlineElements(node); + const attributes = Object.entries(node.attribs) + .map(([key, value]) => ` ${key}="${value}"`) + .join(''); + return `<${node.name}${attributes}>${content}`; +} + +function processInlineElements(node: Element): string { + return node.children.map((c) => processNode(c)).join(''); +} + +function processInlineWrapper(node: Element, marker: string): string { + const content = node.children.map((c) => processNode(c)).join(''); + return `${marker}${content}${marker}`; +} + +function processCode(node: Element): string { + const codeContent = node.children.map((c) => processNode(c)).join(''); + + // Check if this is inside a pre (code block) + if (node.parent && isTag(node.parent) && node.parent.name === 'pre') { + return codeContent; + } + + // Single backtick for inline code + return `\`${codeContent}\``; +} + +function processPre(node: Element): string { + // Get language from class="language-xxx" + const codeChild = node.children.find((c): c is Element => isTag(c) && c.name === 'code'); + const className = codeChild?.attribs.class ?? ''; + const langMatch = className.match(/language-(\S+)/); + const lang = langMatch ? langMatch[1] : ''; + + const codeContent = codeChild + ? codeChild.children.map((c) => processNode(c)).join('') + : node.children.map((c) => processNode(c)).join(''); + + return `\`\`\`${lang}\n${codeContent}\`\`\`\n`; +} + +function processHeading(node: Element, tag: string): string { + const level = tag.charAt(1); + const content = node.children.map((c) => processNode(c)).join(''); + return `${'#'.repeat(parseInt(level, 10))} ${content}\n`; +} + +function processParagraph(node: Element): string { + const content = node.children.map((c) => processNode(c)).join(''); + return `${content}\n`; +} + +function processBlockquote(node: Element): string { + const content = node.children + .map((child) => { + if (isTag(child) && child.name === 'br') return '\n'; + const text = processNode(child); + return text.replace(/\n/g, '\n> '); + }) + .join(''); + return `> ${content}\n`; +} + +/** + * Process children of a list item, separating inline content from nested lists. + * Nested lists are processed with increased depth for indentation. + */ +function processListItemChildren(li: Element, depth: number): string { + const inlineParts: string[] = []; + const nestedParts: string[] = []; + + li.children.forEach((child) => { + if (isTag(child) && (child.name === 'ul' || child.name === 'ol')) { + // Nested list, process with increased depth + nestedParts.push(processNode(child, depth + 1)); + } else if (isTag(child) && child.name === 'p') { + // Unwrap

        inside

      • + inlineParts.push(child.children.map((c) => processNode(c)).join('')); + } else { + inlineParts.push(processNode(child)); + } + }); + + let result = inlineParts.join('').trim(); + if (nestedParts.length > 0) { + result += '\n' + nestedParts.join('').trimEnd(); + } + return result; +} + +function processUnorderedList(node: Element, depth: number = 0): string { + const mdSequence = node.attribs['data-md'] || '-'; + const indent = ' '.repeat(depth); + const items = node.children + .filter((c): c is Element => isTag(c) && c.name === 'li') + .map((li) => { + const content = processListItemChildren(li, depth); + return `${indent}${mdSequence} ${content}`; + }) + .join('\n'); + return items + '\n'; +} + +function processOrderedList(node: Element, depth: number = 0): string { + const mdSequence = node.attribs['data-md'] || '1.'; + const [starOrHyphen] = mdSequence.match(/^\*|-$/) ?? []; + const outPrefix = starOrHyphen + ? starOrHyphen + : mdSequence.endsWith('.') + ? mdSequence + : `${mdSequence}.`; + + const indent = ' '.repeat(depth); + const items = node.children + .filter((c): c is Element => isTag(c) && c.name === 'li') + .map((li, index) => { + let currentPrefix = outPrefix; + if (!starOrHyphen) { + const start = parseInt(node.attribs.start || mdSequence, 10); + if (!isNaN(start)) { + currentPrefix = `${start + index}.`; + } + } + const content = processListItemChildren(li, depth); + return `${indent}${currentPrefix} ${content}`; + }) + .join('\n'); + return items + '\n'; +} + +function processListItem(node: Element): string { + const content = node.children + .map((child) => { + if (isTag(child) && child.name === 'p') { + return child.children.map((c) => processNode(c)).join(''); + } + return processNode(child); + }) + .join(''); + return `- ${content}\n`; +} + +function processSubscript(node: Element): string { + const content = node.children.map((c) => processNode(c)).join(''); + return `-# ${content}\n`; +} + +function processLink(node: Element): string { + const href = node.attribs.href ?? ''; + const content = node.children.map((c) => processNode(c)).join(''); + return `[${content}](${href})`; +} + +function processSpoiler(node: Element): string { + const content = node.children.map((c) => processNode(c)).join(''); + return `||${content}||`; +} + +function processMath(node: Element, mode: 'inline' | 'block'): string { + const latex = node.attribs['data-mx-maths'] ?? ''; + if (mode === 'block') { + return `$$${latex}$$`; + } + return `$${latex}$`; +} + +function processInlineMarkdown(node: Element): string { + const mdSequence = node.attribs['data-md'] ?? ''; + const content = node.children.map((c) => processNode(c)).join(''); + return `${mdSequence}${content}${mdSequence}`; +} + +function processImage(node: Element): string { + if (node.attribs['data-mx-emoticon'] === undefined) { + return ''; + } + + const src = node.attribs.src ?? ''; + const alt = node.attribs.alt ?? ''; + + if (!validateMxcUrl(src)) { + return ''; + } + + return `${alt}`; +} diff --git a/src/app/plugins/markdown/index.ts b/src/app/plugins/markdown/index.ts index 4c4e4491a..36613095f 100644 --- a/src/app/plugins/markdown/index.ts +++ b/src/app/plugins/markdown/index.ts @@ -1,3 +1,3 @@ export * from './utils'; -export * from './block'; -export * from './inline'; +export { markdownToHtml } from './markdownToHtml'; +export { htmlToMarkdown, injectDataMd } from './markdownPipeline'; diff --git a/src/app/plugins/markdown/injectDataMd.test.ts b/src/app/plugins/markdown/injectDataMd.test.ts new file mode 100644 index 000000000..2572c9e20 --- /dev/null +++ b/src/app/plugins/markdown/injectDataMd.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { injectDataMd } from './injectDataMd'; + +describe('injectDataMd', () => { + it('injects data-md into headings', () => { + const result = injectDataMd('

        Hello

        '); + expect(result).toContain('data-md="#'); + }); + + it('does not inject data-md if already present', () => { + const result = injectDataMd('

        Hello

        '); + expect(result).toContain('data-md="#'); + // Should not have duplicate data-md + expect(result.match(/data-md="#/g) ?? []).toHaveLength(1); + }); + + it('injects data-md into blockquotes', () => { + const result = injectDataMd('
        Quote
        '); + expect(result).toContain('data-md=">"'); + }); + + it('injects data-md into code blocks', () => { + const result = injectDataMd('
        code
        '); + expect(result).toContain('data-md="```"'); + }); + + it('injects data-md into horizontal rules', () => { + const result = injectDataMd('
        '); + expect(result).toContain('data-md="---"'); + }); + + it('injects data-md into subscript', () => { + const result = injectDataMd('text'); + expect(result).toContain('data-md="-#"'); + }); + + it('injects data-md into unordered lists', () => { + const result = injectDataMd('
        • Item
        '); + expect(result).toContain('data-md="-"'); + }); + + it('injects data-md into ordered lists', () => { + const result = injectDataMd('
        1. Item
        '); + expect(result).toContain('data-md="1."'); + }); + + it('injects data-md into strong tags', () => { + const result = injectDataMd('bold'); + expect(result).toContain('data-md="**"'); + }); + + it('injects data-md into em tags', () => { + const result = injectDataMd('italic'); + expect(result).toContain('data-md="*"'); + }); + + it('injects data-md into u tags', () => { + const result = injectDataMd('underline'); + expect(result).toContain('data-md="_"'); + }); + + it('injects data-md into s tags', () => { + const result = injectDataMd('strike'); + expect(result).toContain('data-md="~~"'); + }); + + it('injects data-md into del tags', () => { + const result = injectDataMd('deleted'); + expect(result).toContain('data-md="~~"'); + }); + + it('injects data-md into code tags', () => { + const result = injectDataMd('inline'); + expect(result).toContain('data-md="`"'); + }); + + it('handles multiline code blocks without injecting data-md', () => { + const result = injectDataMd('line1\nline2'); + expect(result).not.toContain('data-md'); + }); +}); diff --git a/src/app/plugins/markdown/injectDataMd.ts b/src/app/plugins/markdown/injectDataMd.ts new file mode 100644 index 000000000..e27cfdb7b --- /dev/null +++ b/src/app/plugins/markdown/injectDataMd.ts @@ -0,0 +1,100 @@ +/** + * Injects data-md attributes into HTML to preserve markdown syntax for round-trip editing. + * This is used when receiving HTML from a sender that may not have preserved data-md. + * + * The function identifies common HTML patterns and adds the appropriate data-md attribute + * so that when converting back to markdown, the original syntax is preserved. + * + * @param html - Input HTML string + * @returns HTML string with data-md attributes injected + */ +export function injectDataMd(html: string): string { + // Inject heading data-md (e.g.,

        ) + html = html.replace(/]*)>/g, (_, level, attrs) => { + if (attrs.includes('data-md')) return ``; + const hashes = '#'.repeat(parseInt(level, 10)); + return ``; + }); + + // Inject blockquote data-md + html = html.replace(/]*)>/g, (_, attrs) => { + if (attrs.includes('data-md')) return ``; + return `
        `; + }); + + // Inject code block data-md + html = html.replace(/]*)>]*)>/g, (_, preAttrs, codeAttrs) => { + if (preAttrs.includes('data-md')) return ``; + return `
        `;
        +  });
        +
        +  // Inject horizontal rule data-md
        +  html = html.replace(/]*)>/g, (_, attrs) => {
        +    if (attrs.includes('data-md')) return ``;
        +    return `
        `; + }); + + // Inject subscript data-md + html = html.replace(/]*)>/g, (_, attrs) => { + if (attrs.includes('data-md')) return ``; + return ``; + }); + + // Inject ordered list data-md + html = html.replace(/]*)>/g, (_, attrs) => { + if (attrs.includes('data-md')) return ``; + return `
          `; + }); + + // Inject unordered list data-md + html = html.replace(/]*)>/g, (_, attrs) => { + if (attrs.includes('data-md')) return ``; + return `