*literal*');
+ expect(result).toContain('*literal*');
+ expect(result).not.toMatch(/\\\*literal\\\*/);
+ });
+
+ it('does not escape markdown markers inside inline code', () => {
+ const result = htmlToMarkdown('before*x*after
\\*typed\\*');
+ expect(result).toContain('\\*typed\\*');
+ });
+
it('converts links', () => {
expect(htmlToMarkdown('link')).toContain(
'[link](https://example.com)'
diff --git a/src/app/plugins/markdown/htmlToMarkdown.ts b/src/app/plugins/markdown/htmlToMarkdown.ts
index 26283f514..94c7381bf 100644
--- a/src/app/plugins/markdown/htmlToMarkdown.ts
+++ b/src/app/plugins/markdown/htmlToMarkdown.ts
@@ -60,9 +60,9 @@ function processNodes(nodes: ChildNode[]): string {
.join('');
}
-function processNode(node: ChildNode, listDepth: number = 0): string {
+function processNode(node: ChildNode, listDepth: number = 0, insideCode: boolean = false): string {
if (isText(node)) {
- return escapeMarkdownInlineSequences(node.data);
+ return insideCode ? node.data : escapeMarkdownInlineSequences(node.data);
}
if (!isTag(node)) {
@@ -74,19 +74,19 @@ function processNode(node: ChildNode, listDepth: number = 0): string {
// Handle Matrix-specific attributes
if (tag === 'span') {
if (node.attribs['data-mx-spoiler'] !== undefined) {
- return processSpoiler(node);
+ return processSpoiler(node, listDepth, insideCode);
}
if (node.attribs['data-mx-maths'] !== undefined) {
return processMath(node, 'inline');
}
if (node.attribs['data-md'] !== undefined) {
- return processInlineMarkdown(node);
+ return processInlineMarkdown(node, listDepth, insideCode);
}
if (
node.attribs['data-mx-color'] !== undefined ||
node.attribs['data-mx-bg-color'] !== undefined
) {
- return reconstructTag(node);
+ return reconstructTag(node, listDepth, insideCode);
}
}
@@ -104,18 +104,18 @@ function processNode(node: ChildNode, listDepth: number = 0): string {
case 'h4':
case 'h5':
case 'h6':
- return processHeading(node, tag);
+ return processHeading(node, tag, listDepth, insideCode);
case 'p':
- return processParagraph(node);
+ return processParagraph(node, listDepth, insideCode);
case 'strong':
case 'b':
- return processInlineWrapper(node, '**');
+ return processInlineWrapper(node, '**', listDepth, insideCode);
case 'em':
case 'i':
- return processInlineWrapper(node, '*');
+ return processInlineWrapper(node, '*', listDepth, insideCode);
case 'u': {
const md = node.attribs['data-md'];
@@ -124,28 +124,28 @@ function processNode(node: ChildNode, listDepth: number = 0): string {
case 's':
case 'del':
- return processInlineWrapper(node, '~~');
+ return processInlineWrapper(node, '~~', listDepth, insideCode);
case 'code':
- return processCode(node);
+ return processCode(node, listDepth);
case 'pre':
- return processPre(node);
+ return processPre(node, listDepth);
case 'blockquote':
- return processBlockquote(node);
+ return processBlockquote(node, listDepth, insideCode);
case 'ul':
- return processUnorderedList(node, listDepth);
+ return processUnorderedList(node, listDepth, insideCode);
case 'ol':
- return processOrderedList(node, listDepth);
+ return processOrderedList(node, listDepth, insideCode);
case 'li':
- return processListItem(node);
+ return processListItem(node, listDepth, insideCode);
case 'a':
- return processLink(node);
+ return processLink(node, listDepth, insideCode);
case 'br':
return '\n';
@@ -154,34 +154,43 @@ function processNode(node: ChildNode, listDepth: number = 0): string {
return '---\n';
case 'sub':
- return processSubscript(node);
+ return processSubscript(node, listDepth, insideCode);
case 'img':
return processImage(node);
default:
- return processInlineElements(node);
+ return processInlineElements(node, listDepth, insideCode);
}
}
-function reconstructTag(node: Element): string {
- const content = processInlineElements(node);
+function reconstructTag(node: Element, listDepth: number = 0, insideCode: boolean = false): string {
+ const content = processInlineElements(node, listDepth, insideCode);
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 processInlineElements(
+ node: Element,
+ listDepth: number = 0,
+ insideCode: boolean = false
+): string {
+ return node.children.map((c) => processNode(c, listDepth, insideCode)).join('');
}
-function processInlineWrapper(node: Element, marker: string): string {
- const content = node.children.map((c) => processNode(c)).join('');
+function processInlineWrapper(
+ node: Element,
+ marker: string,
+ listDepth: number = 0,
+ insideCode: boolean = false
+): string {
+ const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join('');
return `${marker}${content}${marker}`;
}
-function processCode(node: Element): string {
- const codeContent = node.children.map((c) => processNode(c)).join('');
+function processCode(node: Element, listDepth: number = 0): string {
+ const codeContent = node.children.map((c) => processNode(c, listDepth, true)).join('');
// Check if this is inside a pre (code block)
if (node.parent && isTag(node.parent) && node.parent.name === 'pre') {
@@ -192,7 +201,7 @@ function processCode(node: Element): string {
return `\`${codeContent}\``;
}
-function processPre(node: Element): string {
+function processPre(node: Element, listDepth: number = 0): 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 ?? '';
@@ -200,28 +209,41 @@ function processPre(node: Element): string {
const lang = langMatch ? langMatch[1] : '';
const codeContent = codeChild
- ? codeChild.children.map((c) => processNode(c)).join('')
- : node.children.map((c) => processNode(c)).join('');
+ ? codeChild.children.map((c) => processNode(c, listDepth, true)).join('')
+ : node.children.map((c) => processNode(c, listDepth, true)).join('');
return `\`\`\`${lang}\n${codeContent}\`\`\`\n`;
}
-function processHeading(node: Element, tag: string): string {
+function processHeading(
+ node: Element,
+ tag: string,
+ listDepth: number = 0,
+ insideCode: boolean = false
+): string {
const level = tag.charAt(1);
- const content = node.children.map((c) => processNode(c)).join('');
+ const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join('');
return `${'#'.repeat(parseInt(level, 10))} ${content}\n`;
}
-function processParagraph(node: Element): string {
- const content = node.children.map((c) => processNode(c)).join('');
+function processParagraph(
+ node: Element,
+ listDepth: number = 0,
+ insideCode: boolean = false
+): string {
+ const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join('');
return `${content}\n`;
}
-function processBlockquote(node: Element): string {
+function processBlockquote(
+ node: Element,
+ listDepth: number = 0,
+ insideCode: boolean = false
+): string {
const content = node.children
.map((child) => {
if (isTag(child) && child.name === 'br') return '\n';
- const text = processNode(child);
+ const text = processNode(child, listDepth, insideCode);
return text.replace(/\n/g, '\n> ');
})
.join('');
@@ -232,19 +254,19 @@ function processBlockquote(node: Element): string {
* 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 {
+function processListItemChildren(li: Element, depth: number, insideCode: boolean = false): 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));
+ nestedParts.push(processNode(child, depth + 1, insideCode));
} else if (isTag(child) && child.name === 'p') {
// Unwrap inside
{
+ const result = markdownToHtml('Hi `\\*x\\*` there');
+ expect(result).toContain('\\*x\\*');
+ });
+
it('does not treat >:3 as a block quote (requires space after >)', () => {
const result = markdownToHtml('>:3');
expect(result).not.toContain('');
diff --git a/src/app/plugins/markdown/markdownToHtml.ts b/src/app/plugins/markdown/markdownToHtml.ts
index 22ac5cd48..f0b1397fe 100644
--- a/src/app/plugins/markdown/markdownToHtml.ts
+++ b/src/app/plugins/markdown/markdownToHtml.ts
@@ -12,7 +12,7 @@ import { matrixEmoticonExtension, preprocessEmoticon } from './extensions/matrix
import { matrixUnderlineExtension } from './extensions/matrix-underline';
import {
escapeLineStartBlockquoteWithoutFollowingSpace,
- unescapeMarkdownInlineSequences,
+ unescapeMarkdownInlineSequencesExceptInCodeHtml,
} from './utils';
// Configure marked with Matrix extensions
@@ -70,8 +70,8 @@ export function markdownToHtml(markdown: string): string {
// Parse markdown to HTML using marked with our Matrix extensions
const html = processor.parse(mathInput) as string;
- // Unescape inline sequences (e.g., \*, \_) after parsing
- const unescapedInline = unescapeMarkdownInlineSequences(html);
+ // Unescape inline sequences (e.g., \*, \_) after parsing, but not inside /
+ const unescapedInline = unescapeMarkdownInlineSequencesExceptInCodeHtml(html);
// Force all links to open in a new tab
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
diff --git a/src/app/plugins/markdown/utils.ts b/src/app/plugins/markdown/utils.ts
index e3cd00cce..822a1e5a1 100644
--- a/src/app/plugins/markdown/utils.ts
+++ b/src/app/plugins/markdown/utils.ts
@@ -27,6 +27,37 @@ export const unescapeMarkdownInlineSequences = (text: string): string => {
return parts.join('');
};
+const PLACEHOLDER_START = '\uE000';
+const PLACEHOLDER_END = '\uE001';
+
+/**
+ * Like {@link unescapeMarkdownInlineSequences}, but leaves <pre>…</pre> and
+ * <code>…</code> regions unchanged so backslash escapes remain literal in HTML
+ * code blocks (CommonMark treats them as verbatim in the source markdown, and the post-parse
+ * HTML pass must not strip viewer-intended `\` characters there).
+ */
+export const unescapeMarkdownInlineSequencesExceptInCodeHtml = (html: string): string => {
+ const preserved: string[] = [];
+ const tag = (idx: number) => `${PLACEHOLDER_START}${idx}${PLACEHOLDER_END}`;
+
+ let masked = html.replace(/]*>[\s\S]*?<\/pre>/gi, (chunk) => {
+ preserved.push(chunk);
+ return tag(preserved.length - 1);
+ });
+
+ masked = masked.replace(/]*>[\s\S]*?<\/code>/gi, (chunk) => {
+ preserved.push(chunk);
+ return tag(preserved.length - 1);
+ });
+
+ const unescaped = unescapeMarkdownInlineSequences(masked);
+
+ return unescaped.replace(
+ new RegExp(`${PLACEHOLDER_START}(\\d+)${PLACEHOLDER_END}`, 'g'),
+ (_, i) => preserved[parseInt(i, 10)] ?? ''
+ );
+};
+
/**
* Recovers the markdown escape sequences in the given plain-text.
* This function adds backslashes (`\`) before markdown characters that may need escaping
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 8fafebafd..26985c66c 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -72,6 +72,8 @@ export interface Settings {
isWidgetDrawer: boolean;
memberSortFilterIndex: number;
enterForNewline: boolean;
+ editorToolbar: boolean;
+ composerToolbarOpen: boolean;
messageLayout: MessageLayout;
messageSpacing: MessageSpacing;
hideMembershipEvents: boolean;
@@ -188,6 +190,8 @@ export const defaultSettings: Settings = {
isWidgetDrawer: false,
memberSortFilterIndex: 0,
enterForNewline: false,
+ editorToolbar: false,
+ composerToolbarOpen: false,
messageLayout: 0,
messageSpacing: '400',
hideMembershipEvents: false,