diff --git a/src/app/components/editor/output.test.ts b/src/app/components/editor/output.test.ts new file mode 100644 index 000000000..238f2d0a1 --- /dev/null +++ b/src/app/components/editor/output.test.ts @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Descendant } from 'slate'; +import { toMatrixCustomHTML } from './output'; +import { BlockType } from './types'; + +// Loose Slate node builders for the test. +const txt = (text: string, marks: Record = {}) => + ({ text, ...marks }) as unknown as Descendant; +const el = (type: BlockType, children: unknown[]) => + ({ type, children }) as unknown as Descendant; + +const OPTS = { + allowTextFormatting: true, + allowInlineMarkdown: false, + allowBlockMarkdown: false, + allowMath: true, +}; + +test('inline $…$ → data-mx-maths span, surrounding prose preserved', () => { + assert.equal( + toMatrixCustomHTML(txt('a $x^2$ b'), OPTS), + 'a x^2 b', + ); +}); + +test('single-line block $$…$$ → data-mx-maths div', () => { + assert.equal( + toMatrixCustomHTML(txt('$$E=mc^2$$'), OPTS), + '
E=mc^2
', + ); +}); + +test('LaTeX special chars are escaped in attribute + fallback', () => { + assert.equal( + toMatrixCustomHTML(txt('$aa<b', + ); +}); + +test('math bypasses markdown — underscores are not italicised', () => { + // Without pre-markdown extraction, `_` would become . + assert.equal( + toMatrixCustomHTML(txt('$a_b$'), { ...OPTS, allowInlineMarkdown: true }), + 'a_b', + ); +}); + +test('currency ($5 and $10) stays literal', () => { + assert.equal(toMatrixCustomHTML(txt('$5 and $10'), OPTS), '$5 and $10'); +}); + +test('no math conversion inside an inline code mark', () => { + const out = toMatrixCustomHTML(txt('$x$', { code: true }), OPTS); + assert.ok(!out.includes('data-mx-maths')); + assert.ok(out.includes('$x$')); +}); + +test('no math conversion inside a code block', () => { + const block = el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('$x$')])]); + const out = toMatrixCustomHTML(block, OPTS); + assert.ok(!out.includes('data-mx-maths')); + assert.ok(out.includes('$x$')); +}); + +test('a message with no math is unchanged', () => { + assert.equal(toMatrixCustomHTML(txt('just hello'), OPTS), 'just hello'); +}); diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 2fca27307..e2fc0680c 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -12,14 +12,42 @@ import { import { findAndReplace } from '../../utils/findAndReplace'; import { sanitizeForRegex } from '../../utils/regex'; import { isUserId } from '../../utils/matrix'; +import { splitMathSegments } from '../../utils/mathParse'; export type OutputOptions = { allowTextFormatting?: boolean; allowInlineMarkdown?: boolean; allowBlockMarkdown?: boolean; + allowMath?: boolean; +}; + +// Spec `data-mx-maths` markup (CS-API §11.5): the attribute holds the LaTeX; the +// child is the fallback for clients that don't render math. sanitizeText +// escapes & < > " ' — correct for both the attribute value and the text. +const mathToCustomHtml = (latex: string, block: boolean): string => { + const esc = sanitizeText(latex); + const tag = block ? 'div' : 'span'; + return `<${tag} data-mx-maths="${esc}">${esc}`; }; const textToCustomHtml = (node: Text, opts: OutputOptions): string => { + // Convert `$…$`/`$$…$$` to `data-mx-maths` so math renders on other clients. + // Extracted BEFORE markdown so LaTeX (`_`, `*`, `\`, `{}`) isn't mangled; never + // applied inside inline code. Non-math text recurses with allowMath off so it + // still gets the normal marks + inline-markdown treatment. + if (opts.allowMath && !node.code) { + const segments = splitMathSegments(node.text); + if (segments.some((seg) => seg.type !== 'text')) { + return segments + .map((seg) => + seg.type === 'text' + ? textToCustomHtml({ ...node, text: seg.value }, { ...opts, allowMath: false }) + : mathToCustomHtml(seg.value, seg.type === 'block') + ) + .join(''); + } + } + let string = sanitizeText(node.text); if (opts.allowTextFormatting) { if (node.bold) string = `${string}`; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c13a3e8cc..dde70dfba 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -514,6 +514,7 @@ export const RoomInput = forwardRef( allowTextFormatting: true, allowBlockMarkdown: isMarkdown, allowInlineMarkdown: isMarkdown, + allowMath: true, }), ); let msgType = MsgType.Text; @@ -616,6 +617,7 @@ export const RoomInput = forwardRef( allowTextFormatting: true, allowBlockMarkdown: isMarkdown, allowInlineMarkdown: isMarkdown, + allowMath: true, }), ); if (plainText === '') return null; diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 5b4b3338e..5c229e2fb 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -117,6 +117,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( allowTextFormatting: true, allowBlockMarkdown: isMarkdown, allowInlineMarkdown: isMarkdown, + allowMath: true, }), );