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 (
-
-
- {child}
-
- );
- if (leaf.spoiler)
- child = (
-
- 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
${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)}${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}
${txt}
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('Quote text'); + expect(result).toContain('>'); + expect(result).toContain('Quote text'); + }); + + it('converts unordered lists', () => { + 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}${node.name}>`; +} + +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') { + // Unwrapinside
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('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., ]*)>/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 `
`; + }); + + // Inject inline markdown markers for strong/bold + html = html.replace(/]*)>([^<]*)<\/strong>/g, (_, attrs, content) => { + if (attrs.includes('data-md')) return `${content}`; + return `${content}`; + }); + html = html.replace(/]*)>([^<]*)<\/b>/g, (_, attrs, content) => { + if (attrs.includes('data-md')) return `${content}`; + return `${content}`; + }); + + // Inject inline markdown markers for emphasis/italic + html = html.replace(/]*)>([^<]*)<\/em>/g, (_, attrs, content) => { + if (attrs.includes('data-md')) return `${content}`; + return `${content}`; + }); + html = html.replace(/]*)>([^<]*)<\/i>/g, (_, attrs, content) => { + if (attrs.includes('data-md')) return `${content}`; + return `${content}`; + }); + + // Inject inline markdown markers for underline + html = html.replace(/]*)>([^<]*)<\/u>/g, (_, attrs, content) => { + if (attrs.includes('data-md')) return `${content}`; + return `${content}`; + }); + + // Inject inline markdown markers for strikethrough + html = html.replace(/
]*)>([^<]*)<\/s>/g, (_, attrs, content) => { + if (attrs.includes('data-md')) return `${content}`; + return `${content}`; + }); + html = html.replace(/]*)>([^<]*)<\/del>/g, (_, attrs, content) => { + if (attrs.includes('data-md')) return `${content}`; + return `${content}`; + }); + + // Inject inline code marker + html = html.replace(/]*)>([^<]*)<\/code>/g, (_, attrs, content) => { + if (attrs.includes('data-md')) return `${content}`; + // Don't add data-md for inline code inside pre (code blocks handled separately) + if (content.includes('\n')) return `${content}`; + return `${content}`; + }); + + return html; +} diff --git a/src/app/plugins/markdown/inline/index.ts b/src/app/plugins/markdown/inline/index.ts deleted file mode 100644 index 75aa8b939..000000000 --- a/src/app/plugins/markdown/inline/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './parser'; diff --git a/src/app/plugins/markdown/inline/parser.ts b/src/app/plugins/markdown/inline/parser.ts deleted file mode 100644 index 95dbeafb8..000000000 --- a/src/app/plugins/markdown/inline/parser.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - BoldRule, - CodeRule, - EscapeRule, - HiddenLinkRule, - ItalicRule1, - ItalicRule2, - LinkRule, - SpoilerRule, - StrikeRule, - UnderlineRule, -} from './rules'; -import { runInlineRule, runInlineRules } from './runner'; -import type { InlineMDParser } from './type'; - -const LeveledRules = [ - HiddenLinkRule, - BoldRule, - ItalicRule1, - UnderlineRule, - ItalicRule2, - StrikeRule, - SpoilerRule, - LinkRule, - EscapeRule, -]; - -/** - * Parses inline markdown text into HTML using defined rules. - * - * @param text - The markdown text to be parsed. - * @returns The parsed HTML or the original text if no markdown was found. - */ -export const parseInlineMD: InlineMDParser = (text) => { - if (text === '') return text; - let result: string | undefined; - if (!result) result = runInlineRule(text, CodeRule, parseInlineMD); - - if (!result) result = runInlineRules(text, LeveledRules, parseInlineMD); - - return result ?? text; -}; diff --git a/src/app/plugins/markdown/inline/rules.ts b/src/app/plugins/markdown/inline/rules.ts deleted file mode 100644 index 11f76d3f0..000000000 --- a/src/app/plugins/markdown/inline/rules.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { InlineMDRule } from './type'; - -const MIN_ANY = '(.+?)'; -const URL_NEG_LB = '(? text.match(BOLD_REG_1), - html: (parse, match) => { - const [, , g2] = match; - if (!g2) return ''; - return `${parse(g2)}`; - }, -}; - -const ITALIC_MD_1 = '*'; -const ITALIC_PREFIX_1 = `${ESC_NEG_LB}\\*`; -const ITALIC_NEG_LA_1 = '(?!\\*)'; -const ITALIC_REG_1 = new RegExp( - `${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}` -); -export const ItalicRule1: InlineMDRule = { - match: (text) => text.match(ITALIC_REG_1), - html: (parse, match) => { - const [, , g2] = match; - if (!g2) return ''; - return `${parse(g2)}`; - }, -}; - -const ITALIC_MD_2 = '_'; -const ITALIC_PREFIX_2 = `${ESC_NEG_LB}_`; -const ITALIC_NEG_LA_2 = '(?!_)'; -const ITALIC_REG_2 = new RegExp( - `${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}` -); -export const ItalicRule2: InlineMDRule = { - match: (text) => text.match(ITALIC_REG_2), - html: (parse, match) => { - const [, , g2] = match; - if (!g2) return ''; - return `${parse(g2)}`; - }, -}; - -const UNDERLINE_MD_1 = '__'; -const UNDERLINE_PREFIX_1 = `${ESC_NEG_LB}_{2}`; -const UNDERLINE_NEG_LA_1 = '(?!_)'; -const UNDERLINE_REG_1 = new RegExp( - `${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}` -); -export const UnderlineRule: InlineMDRule = { - match: (text) => text.match(UNDERLINE_REG_1), - html: (parse, match) => { - const [, , g2] = match; - if (!g2) return ''; - return `${parse(g2)}`; - }, -}; - -const STRIKE_MD_1 = '~~'; -const STRIKE_PREFIX_1 = `${ESC_NEG_LB}~{2}`; -const STRIKE_NEG_LA_1 = '(?!~)'; -const STRIKE_REG_1 = new RegExp( - `${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}` -); -export const StrikeRule: InlineMDRule = { - match: (text) => text.match(STRIKE_REG_1), - html: (parse, match) => { - const [, , g2] = match; - if (!g2) return ''; - return `${parse(g2)}`; - }, -}; - -const CODE_MD_1 = '`'; -const CODE_PREFIX_1 = `${ESC_NEG_LB}\``; -const CODE_NEG_LA_1 = '(?!`)'; -const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`); -export const CodeRule: InlineMDRule = { - match: (text) => text.match(CODE_REG_1), - html: (parse, match) => { - const [, , g2] = match; - if (!g2) return ''; - return `${g2}`; - }, -}; - -const SPOILER_MD_1 = '||'; -const SPOILER_PREFIX_1 = `${ESC_NEG_LB}\\|{2}`; -const SPOILER_NEG_LA_1 = '(?!\\|)'; -const SPOILER_REG_1 = new RegExp( - `${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}` -); -export const SpoilerRule: InlineMDRule = { - match: (text) => text.match(SPOILER_REG_1), - html: (parse, match) => { - const [, , g2] = match; - if (!g2) return ''; - return `${parse(g2)}`; - }, -}; - -const LINK_ALT = `\\[${MIN_ANY}\\]`; -const LINK_URL = `\\(((<)?https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)(>)?\\)`; -const LINK_REG_1 = new RegExp(`(<)?${LINK_ALT}${LINK_URL}(>)?`); -export const LinkRule: InlineMDRule = { - match: (text) => text.match(LINK_REG_1), - html: (parse, match) => { - const [, , g1, g2] = match; - if (!g1 || !g2) return ''; - if (g2.startsWith('<') && g2.endsWith('>')) - return `${parse(g1)}`; - - return `${parse(g1)}`; - }, -}; -const HIDDEN_LINK_URL = `<(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)>`; -const HIDDEN_LINK_REG_1 = new RegExp(HIDDEN_LINK_URL); -export const HiddenLinkRule: InlineMDRule = { - match: (text) => text.match(HIDDEN_LINK_REG_1), - html: (parse, match) => { - const [, g1] = match; - if (!g1) return ''; - return g1; - }, -}; - -export const INLINE_SEQUENCE_SET = '[*_~`|]'; -export const CAP_INLINE_SEQ = `${URL_NEG_LB}${INLINE_SEQUENCE_SET}`; -const ESC_SEQ_1 = `\\\\(${INLINE_SEQUENCE_SET})`; -const ESC_REG_1 = new RegExp(`${URL_NEG_LB}${ESC_SEQ_1}`); -export const EscapeRule: InlineMDRule = { - match: (text) => text.match(ESC_REG_1), - html: (_parse, match) => { - const [, , g2] = match; - return g2 ?? ''; - }, -}; diff --git a/src/app/plugins/markdown/inline/runner.ts b/src/app/plugins/markdown/inline/runner.ts deleted file mode 100644 index e0a7b9e00..000000000 --- a/src/app/plugins/markdown/inline/runner.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { MatchResult } from '$plugins/markdown/internal'; -import { replaceMatch } from '$plugins/markdown/internal'; -import type { InlineMDParser, InlineMDRule } from './type'; - -/** - * Runs a single markdown rule on the provided text. - * - * @param text - The text to parse. - * @param rule - The markdown rule to run. - * @param parse - A function that run the parser on remaining parts. - * @returns The text with the markdown rule applied or `undefined` if no match is found. - */ -export const runInlineRule = ( - text: string, - rule: InlineMDRule, - parse: InlineMDParser -): string | undefined => { - const matchResult = rule.match(text); - if (matchResult) { - const content = rule.html(parse, matchResult); - return replaceMatch(text, matchResult, content, (txt) => [parse(txt)]).join(''); - } - return undefined; -}; - -/** - * Runs multiple rules at the same time to better handle nested rules. - * Rules will be run in the order they appear. - * - * @param text - The text to parse. - * @param rules - The markdown rules to run. - * @param parse - A function that run the parser on remaining parts. - * @returns The text with the markdown rules applied or `undefined` if no match is found. - */ -export const runInlineRules = ( - text: string, - rules: InlineMDRule[], - parse: InlineMDParser -): string | undefined => { - const matchResults = rules.map((rule) => rule.match(text)); - - let targetRule: InlineMDRule | undefined; - let targetResult: MatchResult | undefined; - - for (let i = 0; i < matchResults.length; i += 1) { - const currentResult = matchResults[i]; - if (currentResult && typeof currentResult.index === 'number') { - if ( - !targetResult || - (typeof targetResult?.index === 'number' && currentResult.index < targetResult.index) - ) { - targetResult = currentResult; - targetRule = rules[i]; - } - } - } - - if (targetRule && targetResult) { - const content = targetRule.html(parse, targetResult); - return replaceMatch(text, targetResult, content, (txt) => [parse(txt)]).join(''); - } - return undefined; -}; diff --git a/src/app/plugins/markdown/inline/type.ts b/src/app/plugins/markdown/inline/type.ts deleted file mode 100644 index e9161211f..000000000 --- a/src/app/plugins/markdown/inline/type.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { MatchResult, MatchRule } from '$plugins/markdown/internal'; - -/** - * Type for a function that parses inline markdown into HTML. - * - * @param text - The markdown text to be parsed. - * @returns The parsed HTML. - */ -export type InlineMDParser = (text: string) => string; - -/** - * Type for a function that converts a match to output. - * - * @param parse - The inline markdown parser function. - * @param match - The match result. - * @returns The output string after processing the match. - */ -export type InlineMatchConverter = (parse: InlineMDParser, match: MatchResult) => string; - -/** - * Type representing a markdown rule that includes a matching pattern and HTML conversion. - */ -export type InlineMDRule = { - match: MatchRule; // A function that matches a specific markdown pattern. - html: InlineMatchConverter; // A function that converts the match to HTML. -}; diff --git a/src/app/plugins/markdown/internal/index.ts b/src/app/plugins/markdown/internal/index.ts deleted file mode 100644 index 04bca77e0..000000000 --- a/src/app/plugins/markdown/internal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './utils'; diff --git a/src/app/plugins/markdown/internal/utils.ts b/src/app/plugins/markdown/internal/utils.ts deleted file mode 100644 index 86bc9d5c5..000000000 --- a/src/app/plugins/markdown/internal/utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @typedef {RegExpMatchArray | RegExpExecArray} MatchResult - * - * Represents the result of a regular expression match. - * This type can be either a `RegExpMatchArray` or a `RegExpExecArray`, - * which are returned when performing a match with a regular expression. - * - * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec} - * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match} - */ -export type MatchResult = RegExpMatchArray | RegExpExecArray; - -/** - * @typedef {function(string): MatchResult | null} MatchRule - * - * A function type that takes a string and returns a `MatchResult` or `null` if no match is found. - * - * @param {string} text The string to match against. - * @returns {MatchResult | null} The result of the regular expression match, or `null` if no match is found. - */ -export type MatchRule = (text: string) => MatchResult | null; - -/** - * Returns the part of the text before a match. - * - * @param text - The input text string. - * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). - * @returns A string containing the part of the text before the match. - */ -export const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => - text.slice(0, match.index); - -/** - * Returns the part of the text after a match. - * - * @param text - The input text string. - * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). - * @returns A string containing the part of the text after the match. - */ -export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => - text.slice((match.index ?? 0) + match[0].length); - -/** - * Replaces a match in the text with a content. - * - * @param text - The input text string. - * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). - * @param content - The content to replace the match with. - * @param processPart - A function to further process remaining parts of the text. - * @returns An array containing the processed parts of the text, including the content. - */ -export const replaceMatch =( - text: string, - match: MatchResult, - content: C, - processPart: (txt: string) => Array -): Array => [ - ...processPart(beforeMatch(text, match)), - content, - ...processPart(afterMatch(text, match)), -]; diff --git a/src/app/plugins/markdown/markdownPipeline.ts b/src/app/plugins/markdown/markdownPipeline.ts new file mode 100644 index 000000000..733cac0cc --- /dev/null +++ b/src/app/plugins/markdown/markdownPipeline.ts @@ -0,0 +1,2 @@ +export { htmlToMarkdown } from './htmlToMarkdown'; +export { injectDataMd } from './injectDataMd'; diff --git a/src/app/plugins/markdown/markdownToHtml.test.ts b/src/app/plugins/markdown/markdownToHtml.test.ts new file mode 100644 index 000000000..24a7592ae --- /dev/null +++ b/src/app/plugins/markdown/markdownToHtml.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { markdownToHtml } from './markdownToHtml'; + +describe('markdownToHtml', () => { + it('converts headings', () => { + const result = markdownToHtml('# Hello World'); + expect(result).toContain(' { + const result = markdownToHtml('**bold**'); + expect(result).toContain('bold'); + }); + + it('converts italic text', () => { + const result = markdownToHtml('*italic*'); + expect(result).toContain('italic'); + }); + + it('converts inline code', () => { + const result = markdownToHtml('`code`'); + expect(result).toContain('
code'); + }); + + it('converts links', () => { + const result = markdownToHtml('[link](https://example.com)'); + expect(result).toContain(' { + const result = markdownToHtml('||spoiler||'); + expect(result).toContain('data-mx-spoiler'); + expect(result).toContain('spoiler'); + }); + + it('converts inline math syntax', () => { + const result = markdownToHtml('$E = mc^2$'); + expect(result).toContain('data-mx-maths'); + expect(result).toContain('E = mc^2'); + }); + + it('converts block math syntax', () => { + const result = markdownToHtml('$$\\frac{a}{b}$$'); + expect(result).toContain('data-mx-maths'); + expect(result).toContain('{ + const result = markdownToHtml('k. Hello world'); + expect(result).not.toContain('- '); + expect(result).not.toContain('
'); + expect(result).not.toContain('
'); + }); + + it('handles text without markdown', () => { + const result = markdownToHtml('Plain text without any formatting'); + expect(result).toContain('Plain text'); + }); + + it('handles multiline content', () => { + const result = markdownToHtml('Line 1\nLine 2\nLine 3'); + expect(result).toContain('Line 1'); + expect(result).toContain('Line 2'); + }); + + it('handles escaped markdown characters', () => { + const result = markdownToHtml('This is \\*not bold\\*'); + expect(result).not.toContain(''); + expect(result).toContain('not bold'); + }); + + it('preserves img[data-mx-emoticon] tags with valid mxc URLs', () => { + const html = + '
'; + const result = markdownToHtml(html); + expect(result).toContain('mxc://example.org/emote'); + expect(result).toContain('data-mx-emoticon'); + }); + + it('rejects img tags with non-mxc protocols', () => { + const html = '
'; + const result = markdownToHtml(html); + expect(result).not.toContain('https://evil.com'); + }); + + it('rejects img tags with javascript: protocol', () => { + const html = '
'; + const result = markdownToHtml(html); + expect(result).not.toContain('javascript:'); + }); + + it('rejects img tags with data: protocol', () => { + const html = + '
'; + const result = markdownToHtml(html); + expect(result).not.toContain('data:'); + }); + + it('rejects img tags with mxc URL containing credentials', () => { + const html = '
'; + const result = markdownToHtml(html); + expect(result).not.toContain('user:pass'); + }); + + it('rejects img tags with mxc URL containing search params', () => { + const html = '
'; + const result = markdownToHtml(html); + expect(result).not.toContain('?'); + }); +}); diff --git a/src/app/plugins/markdown/markdownToHtml.ts b/src/app/plugins/markdown/markdownToHtml.ts new file mode 100644 index 000000000..02338da9e --- /dev/null +++ b/src/app/plugins/markdown/markdownToHtml.ts @@ -0,0 +1,140 @@ +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; +import { matrixSpoilerExtension } from './extensions/matrix-spoiler'; +import { matrixMathExtension, matrixMathBlockExtension } from './extensions/matrix-math'; +import { matrixSubscriptExtension } from './extensions/matrix-subscript'; +import { matrixEmoticonExtension, preprocessEmoticon } from './extensions/matrix-emoticon'; +import { unescapeMarkdownBlockSequences, unescapeMarkdownInlineSequences } from './utils'; + +// Configure marked with Matrix extensions +const processor = marked.use({ + breaks: true, + extensions: [ + matrixSpoilerExtension, + matrixMathExtension, + matrixMathBlockExtension, + matrixSubscriptExtension, + matrixEmoticonExtension, + ], +}); + +/** + * Decodes common HTML entities in text for markdown processing. + * This allows markdown parsers to correctly interpret entities like < as <. + */ +const decodeHtmlEntities = (text: string): string => { + const entities: Record
= { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + ''': "'", + ' ': ' ', + }; + let result = text; + for (const [entity, char] of Object.entries(entities)) { + result = result.split(entity).join(char); + } + return result; +}; + +/** + * Converts markdown string to sanitized Matrix-compatible HTML. + * Uses marked for parsing and DOMPurify for sanitization per Matrix spec. + * + * @param markdown - Input markdown string + * @returns Sanitized HTML string safe for Matrix client output + */ +export function markdownToHtml(markdown: string): string { + // Decode HTML entities so marked can properly parse markdown syntax + // (e.g., < becomes < for link URLs) + const decoded = decodeHtmlEntities(markdown); + + // First unescape any block-level escape sequences (e.g., \>, \#) + const unescapedBlocks = unescapeMarkdownBlockSequences(decoded, (text) => text); + + const preprocessed = preprocessEmoticon(unescapedBlocks); + + // Parse markdown to HTML using marked with our Matrix extensions + const html = processor.parse(preprocessed) as string; + + // Unescape inline sequences (e.g., \*, \_) after parsing + const unescapedInline = unescapeMarkdownInlineSequences(html); + + // Force all links to open in a new tab + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'A' && node.getAttribute('href')) { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noreferrer noopener'); + } + }); + + const sanitized = DOMPurify.sanitize(unescapedInline, { + ALLOWED_TAGS: [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'br', + 'hr', + 'blockquote', + 'ul', + 'ol', + 'li', + 'pre', + 'code', + 'strong', + 'em', + 'u', + 's', + 'del', + 'a', + 'img', + 'span', + 'div', + 'sub', + 'details', + 'summary', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'mx-reply', + ], + ALLOWED_ATTR: [ + 'href', + 'src', + 'alt', + 'title', + 'height', + 'width', + 'target', + 'rel', + 'data-mx-emoticon', + 'data-mx-spoiler', + 'data-mx-maths', + 'data-md', + 'data-mx-color', + 'data-mx-bg-color', + 'data-lang', + 'class', + 'start', + 'type', + 'open', + ], + // Allow safe rel attributes for links + ADD_ATTR: ['target', 'rel'], + // Force all links to have safe rel attribute + FORCE_BODY: false, + ALLOWED_URI_REGEXP: /^(?:https?|ftp|mailto|magnet|mxc):/i, + }); + + DOMPurify.removeHook('afterSanitizeAttributes'); + + return sanitized.replace(/ - (
<\/p>)?<\/li>/gi, '
- '); +} diff --git a/src/app/plugins/markdown/utils.ts b/src/app/plugins/markdown/utils.ts index 8a0de61b0..e15350e2d 100644 --- a/src/app/plugins/markdown/utils.ts +++ b/src/app/plugins/markdown/utils.ts @@ -1,8 +1,14 @@ import { findAndReplace } from '$utils/findAndReplace'; -import { ESC_BLOCK_SEQ, UN_ESC_BLOCK_SEQ } from './block/rules'; -import { EscapeRule, CAP_INLINE_SEQ } from './inline/rules'; -import { runInlineRule } from './inline/runner'; -import { replaceMatch } from './internal'; + +// Regex patterns for block-level markdown escape sequences +// These match escaped markdown characters like \>, \#, \`, etc. +const ESC_BLOCK_SEQ = /^\\(\\*[#>[ `])/; +const UN_ESC_BLOCK_SEQ = /^\*[#>[ `]/; + +// URL-aware pattern for inline sequences +const URL_NEG_LB = '(? - runInlineRule(text, EscapeRule, (t) => { - if (t === '') return t; - return unescapeMarkdownInlineSequences(t); - }) ?? text; +export const unescapeMarkdownInlineSequences = (text: string): string => { + const escapePattern = new RegExp(`${URL_NEG_LB}\\\\(${INLINE_SEQUENCE_SET})`, 'g'); + const parts = findAndReplace( + text, + escapePattern, + (match) => { + const [, g1] = match; + return g1 ?? ''; + }, + (t) => t + ); + return parts.join(''); +}; /** * Recovers the markdown escape sequences in the given plain-text. @@ -59,7 +73,7 @@ export const unescapeMarkdownBlockSequences = ( if (!match) return processPart(text); const [, g1] = match; - return replaceMatch(text, match, g1, (t) => [processPart(t)]).join(''); + return text.replace(ESC_BLOCK_SEQ, g1 ?? ''); }; /** @@ -79,5 +93,5 @@ export const escapeMarkdownBlockSequences = ( if (!match) return processPart(text); const [, g1] = match; - return replaceMatch(text, match, `\\${g1}`, (t) => [processPart(t)]).join(''); + return text.replace(UN_ESC_BLOCK_SEQ, `\\${g1}`); }; diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index cba5cee8c..39ed7b4b1 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -1,6 +1,6 @@ /* oxlint-disable jsx-a11y/alt-text */ import type { CSSProperties, ComponentPropsWithoutRef, ReactEventHandler, ReactNode } from 'react'; -import { Fragment, useMemo, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; import type { HTMLReactParserOptions } from 'html-react-parser'; import { attributesToProps, domToReact, Element, Text as DOMText } from 'html-react-parser'; import type { MatrixClient } from '$types/matrix-sdk'; @@ -9,6 +9,7 @@ import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem import type { IntermediateRepresentation, OptFn, Opts as LinkifyOpts } from 'linkifyjs'; import Linkify from 'linkify-react'; import type { ChildNode } from 'domhandler'; + import * as css from '$styles/CustomHtml.css'; import { getCanonicalAliasRoomId, @@ -96,6 +97,43 @@ const ensureNoopenerRel = (rel: unknown): string => { return parts.join(' '); }; +function KatexRenderer({ + math, + displayMode, + style, +}: { + math: string; + displayMode: boolean; + style?: CSSProperties; +}) { + const [html, setHtml] = useState
(null); + + useEffect(() => { + let mounted = true; + Promise.all([import('katex'), import('katex/dist/katex.min.css')]).then(([katex]) => { + if (mounted) { + setHtml(katex.default.renderToString(math, { throwOnError: false, displayMode })); + } + }); + return () => { + mounted = false; + }; + }, [math, displayMode]); + + if (html === null) { + return ( + + {displayMode ? '$$\n' : '$'} + {math} + {displayMode ? '\n$$' : '$'} ++ ); + } + + const Tag = displayMode ? 'div' : 'span'; + return; +} + export const makeMentionCustomProps = ( handleMentionClick?: ReactEventHandler , content?: string @@ -249,7 +287,11 @@ export const factoryRenderLinkifyWithMention = ( } } - return {content}; + return ( + + {content} + + ); }; return renderLink; @@ -301,17 +343,19 @@ export const highlightText = ( * @returns {string} The concatenated plain text content of all descendant text nodes. */ const extractTextFromChildren = (nodes: ChildNode[]): string => { - let text = ''; - - nodes.forEach((node) => { - if ((node.type as unknown as string) === 'text') { - text += (node as unknown as Text).data; - } else if (node instanceof Element && node.children) { - text += extractTextFromChildren(node.children); - } - }); + const worker = (n: ChildNode[]): string => { + let text = ''; + n.forEach((node) => { + if ((node.type as unknown as string) === 'text') { + text += (node as unknown as Text).data; + } else if (node instanceof Element && node.children) { + text += worker(node.children); + } + }); + return text; + }; - return text; + return worker(nodes).replace(/\n$/, ''); }; const getLanguageFromClassName = (className?: string): string | undefined => { @@ -605,9 +649,10 @@ export const getReactCustomHtmlParser = ( parent instanceof Element ? parent.children : [], parent instanceof Element ? parent.attribs : undefined ); + const trimmedCode = codeContent.replace(/\n$/, ''); return ( ; + } + } + + if (name === 'div' && 'data-mx-maths' in props) { + const math = props['data-mx-maths']; + if (typeof math === 'string') { + return ; + } + } + if (name === 'span' && matrixColorStyle) { return ( diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 83532c673..d71ca4f00 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -34,8 +34,6 @@ export interface Settings { arboriumDarkTheme?: string; saturationLevel?: number; uniformIcons: boolean; - isMarkdown: boolean; - editorToolbar: boolean; twitterEmoji: boolean; pageZoom: number; hideActivity: boolean; @@ -134,8 +132,6 @@ const defaultSettings: Settings = { arboriumDarkTheme: 'dracula', saturationLevel: 100, uniformIcons: false, - isMarkdown: true, - editorToolbar: false, twitterEmoji: true, pageZoom: 100, hideActivity: false, diff --git a/src/app/utils/findAndReplace.ts b/src/app/utils/findAndReplace.ts index a4bd1edb9..51d3a1d4f 100644 --- a/src/app/utils/findAndReplace.ts +++ b/src/app/utils/findAndReplace.ts @@ -20,6 +20,7 @@ export const findAndReplace = ( lastEnd = match.index + match[0].length; if (regex.global) match = regex.exec(text); + else match = null; } result.push(convertPart(text.slice(lastEnd), result.length)); diff --git a/src/app/utils/sendFeedbackToUser.ts b/src/app/utils/sendFeedbackToUser.ts index 124cc4b0a..03fd3a6fa 100644 --- a/src/app/utils/sendFeedbackToUser.ts +++ b/src/app/utils/sendFeedbackToUser.ts @@ -3,7 +3,7 @@ import { DuplicateStrategy, MatrixEvent } from '$types/matrix-sdk'; export function sendFeedback(msg: string, room: Room, userId: string) { const localNotice = new MatrixEvent({ - type: 'm.room_message', + type: 'm.room.message', content: { msgtype: 'm.notice', body: msg }, event_id: `~sable-feedback-${Date.now()}`, room_id: room.roomId, diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 67898886f..c0d617752 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -41,7 +41,6 @@ describe('NON_SYNCABLE_KEYS', () => { it('does not include ordinary syncable keys', () => { const syncable = [ - 'isMarkdown', 'twitterEmoji', 'messageLayout', 'urlPreview', @@ -65,9 +64,8 @@ describe('serializeForSync', () => { }); it('includes syncable settings fields', () => { - const settings = { ...base, isMarkdown: false, twitterEmoji: false }; + const settings = { ...base, twitterEmoji: false }; const { settings: s } = serializeForSync(settings); - expect(s.isMarkdown).toBe(false); expect(s.twitterEmoji).toBe(false); }); @@ -126,11 +124,10 @@ describe('deserializeFromSync', () => { it('merges remote settings over local', () => { const remote = { v: SETTINGS_SYNC_VERSION, - settings: { isMarkdown: false, urlPreview: false }, + settings: { urlPreview: false }, }; - const result = deserializeFromSync(remote, { ...base, isMarkdown: true, urlPreview: true }); + const result = deserializeFromSync(remote, { ...base, urlPreview: true }); expect(result).not.toBeNull(); - expect(result!.isMarkdown).toBe(false); expect(result!.urlPreview).toBe(false); }); @@ -154,11 +151,10 @@ describe('deserializeFromSync', () => { }); it('round-trips through serialize then deserialize correctly', () => { - const tweaked = { ...base, isMarkdown: false, hour24Clock: true }; + const tweaked = { ...base, hour24Clock: true }; const payload = serializeForSync(tweaked); const result = deserializeFromSync(payload, base); expect(result).not.toBeNull(); - expect(result!.isMarkdown).toBe(false); expect(result!.hour24Clock).toBe(true); // non-syncable comes from base, not tweaked (pageZoom etc. same anyway) expect(result!.settingsSyncEnabled).toBe(base.settingsSyncEnabled); @@ -167,11 +163,11 @@ describe('deserializeFromSync', () => { it('ignores extra unknown keys in the remote payload', () => { const remote = { v: SETTINGS_SYNC_VERSION, - settings: { isMarkdown: false, __unknown: 'surprise' }, + settings: { twitterEmoji: false, __unknown: 'surprise' }, }; const result = deserializeFromSync(remote, base); expect(result).not.toBeNull(); - expect(result!.isMarkdown).toBe(false); + expect(result!.twitterEmoji).toBe(false); }); }); @@ -284,7 +280,7 @@ describe('importSettingsFromJson', () => { }); it('resolves merged settings when a valid JSON file is provided', async () => { - const payload = { v: SETTINGS_SYNC_VERSION, settings: { isMarkdown: false } }; + const payload = { v: SETTINGS_SYNC_VERSION, settings: { twitterEmoji: false } }; const fileContent = JSON.stringify(payload); const file = new File([fileContent], 'settings.json', { type: 'application/json' }); @@ -292,14 +288,14 @@ describe('importSettingsFromJson', () => { const fakeFileList = { 0: file, length: 1, item: () => file } as unknown as FileList; mockInput.files = fakeFileList; - const promise = importSettingsFromJson({ ...base, isMarkdown: true }); + const promise = importSettingsFromJson({ ...base, twitterEmoji: true }); // Trigger the change event; the file reader will asynchronously call onload. changeListener?.(new Event('change')); const result = await promise; expect(result).not.toBeNull(); - expect(result!.isMarkdown).toBe(false); + expect(result!.twitterEmoji).toBe(false); }); it('resolves null when the file contains invalid JSON', async () => { @@ -314,7 +310,7 @@ describe('importSettingsFromJson', () => { }); it('resolves null when the JSON has an incompatible schema version', async () => { - const payload = { v: 99, settings: { isMarkdown: false } }; + const payload = { v: 99, settings: { twitterEmoji: false } }; const file = new File([JSON.stringify(payload)], 'settings.json', { type: 'application/json', });