feat(math): emit data-mx-maths on send for cross-client LaTeX
Math rendering already shipped (both the $…$ shorthand and the spec
data-mx-maths form render incoming). But the composer emitted no math, so a
Lotus user's $E=mc^2$ went out as raw text — it rendered on Lotus (both sides
parse $…$) but showed as literal text on Element and other clients.
toMatrixCustomHTML now converts $…$/$$…$$ to
<span|div data-mx-maths="LATEX"><code>LATEX</code></span|div>
(spec CS-API §11.5), reusing the existing splitMathSegments parser. Math is
extracted BEFORE markdown so LaTeX (_, *, \, {}) isn't mangled, and the emitted
span survives the block-markdown pass via the existing ignoreHTMLParseInlineMD
HTML-tag guard. A new allowMath opt threads through the top-level call sites;
code-line/code-block paths use empty opts so math is off inside code. The plain
body keeps literal $…$ as the fallback.
Scope: inline $…$ + single-line $$…$$. Multi-line block $$ (spans editor
paragraph nodes) deferred — still renders on Lotus via the plain-body path.
New output.test.ts (8 cases): span/div emission, escaping, markdown-bypass,
currency non-match, code-mark + code-block exclusion. 738 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown> = {}) =>
|
||||||
|
({ 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 <span data-mx-maths="x^2"><code>x^2</code></span> b',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single-line block $$…$$ → data-mx-maths div', () => {
|
||||||
|
assert.equal(
|
||||||
|
toMatrixCustomHTML(txt('$$E=mc^2$$'), OPTS),
|
||||||
|
'<div data-mx-maths="E=mc^2"><code>E=mc^2</code></div>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LaTeX special chars are escaped in attribute + fallback', () => {
|
||||||
|
assert.equal(
|
||||||
|
toMatrixCustomHTML(txt('$a<b$'), OPTS),
|
||||||
|
'<span data-mx-maths="a<b"><code>a<b</code></span>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('math bypasses markdown — underscores are not italicised', () => {
|
||||||
|
// Without pre-markdown extraction, `_` would become <em>.
|
||||||
|
assert.equal(
|
||||||
|
toMatrixCustomHTML(txt('$a_b$'), { ...OPTS, allowInlineMarkdown: true }),
|
||||||
|
'<span data-mx-maths="a_b"><code>a_b</code></span>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<code>$x$</code>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
@@ -12,14 +12,42 @@ import {
|
|||||||
import { findAndReplace } from '../../utils/findAndReplace';
|
import { findAndReplace } from '../../utils/findAndReplace';
|
||||||
import { sanitizeForRegex } from '../../utils/regex';
|
import { sanitizeForRegex } from '../../utils/regex';
|
||||||
import { isUserId } from '../../utils/matrix';
|
import { isUserId } from '../../utils/matrix';
|
||||||
|
import { splitMathSegments } from '../../utils/mathParse';
|
||||||
|
|
||||||
export type OutputOptions = {
|
export type OutputOptions = {
|
||||||
allowTextFormatting?: boolean;
|
allowTextFormatting?: boolean;
|
||||||
allowInlineMarkdown?: boolean;
|
allowInlineMarkdown?: boolean;
|
||||||
allowBlockMarkdown?: boolean;
|
allowBlockMarkdown?: boolean;
|
||||||
|
allowMath?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spec `data-mx-maths` markup (CS-API §11.5): the attribute holds the LaTeX; the
|
||||||
|
// <code> child is the fallback for clients that don't render math. sanitizeText
|
||||||
|
// escapes & < > " ' — correct for both the attribute value and the <code> text.
|
||||||
|
const mathToCustomHtml = (latex: string, block: boolean): string => {
|
||||||
|
const esc = sanitizeText(latex);
|
||||||
|
const tag = block ? 'div' : 'span';
|
||||||
|
return `<${tag} data-mx-maths="${esc}"><code>${esc}</code></${tag}>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
|
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);
|
let string = sanitizeText(node.text);
|
||||||
if (opts.allowTextFormatting) {
|
if (opts.allowTextFormatting) {
|
||||||
if (node.bold) string = `<strong>${string}</strong>`;
|
if (node.bold) string = `<strong>${string}</strong>`;
|
||||||
|
|||||||
@@ -514,6 +514,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
allowTextFormatting: true,
|
allowTextFormatting: true,
|
||||||
allowBlockMarkdown: isMarkdown,
|
allowBlockMarkdown: isMarkdown,
|
||||||
allowInlineMarkdown: isMarkdown,
|
allowInlineMarkdown: isMarkdown,
|
||||||
|
allowMath: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
let msgType = MsgType.Text;
|
let msgType = MsgType.Text;
|
||||||
@@ -616,6 +617,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
allowTextFormatting: true,
|
allowTextFormatting: true,
|
||||||
allowBlockMarkdown: isMarkdown,
|
allowBlockMarkdown: isMarkdown,
|
||||||
allowInlineMarkdown: isMarkdown,
|
allowInlineMarkdown: isMarkdown,
|
||||||
|
allowMath: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (plainText === '') return null;
|
if (plainText === '') return null;
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
allowTextFormatting: true,
|
allowTextFormatting: true,
|
||||||
allowBlockMarkdown: isMarkdown,
|
allowBlockMarkdown: isMarkdown,
|
||||||
allowInlineMarkdown: isMarkdown,
|
allowInlineMarkdown: isMarkdown,
|
||||||
|
allowMath: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user