Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions tests/e2e/mobile-display-math.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect, test } from '@playwright/test'
import { openLocalDraft } from './helpers/drafts'

test.describe.configure({ timeout: 60000 })

// Upstream marktext#4935 (issue #4904): `$$...$$` on the same line as other
// text renders as display math instead of showing raw `$$` markers — markdown
// pasted from pandoc/GitHub-style sources now displays correctly. The Muya
// unit specs cover tokenization and serialization in happy-dom; this covers
// what happy-dom cannot: the shipped CSS renders the hidden preview as a
// full-width centered block in a real Chromium layout, while single-dollar
// math stays inline.

const MARKDOWN = 'Energy is $$E=mc^2$$ conserved, inline $x$ stays inline.'

test('renders same-line double-dollar math as a centered display block', async ({ page }) => {
await openLocalDraft(page, {
id: 'display-math-draft',
markdown: MARKDOWN,
title: /Energy is/,
})

const display = page.locator('.mu-math.mu-display-math').first()
await expect(display).toBeVisible()
await expect(display.locator('.katex-display')).toHaveCount(1)

const layout = await display.evaluate(element => {
const paragraph = element.closest('p.mu-paragraph')!
const katex = element.querySelector('.katex')!
const elementRect = element.getBoundingClientRect()
const paragraphRect = paragraph.getBoundingClientRect()
const katexRect = katex.getBoundingClientRect()

return {
display: getComputedStyle(element).display,
widthDelta: Math.abs(elementRect.width - paragraphRect.width),
centerDelta: Math.abs(
katexRect.left + katexRect.width / 2 - (paragraphRect.left + paragraphRect.width / 2),
),
}
})
expect(layout.display).toBe('block')
expect(layout.widthDelta).toBeLessThanOrEqual(2)
expect(layout.centerDelta).toBeLessThanOrEqual(2)

// Single-dollar math is untouched: inline preview, no display block.
const inline = page.locator('.mu-math:not(.mu-display-math)').first()
await expect(inline).toBeVisible()
await expect(inline.locator('.katex-display')).toHaveCount(0)
})
7 changes: 7 additions & 0 deletions third_party/muya/src/assets/styles/inlineSyntax.css
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,13 @@ div .mu-math-error,
vertical-align: middle;
}

.mu-hide.mu-math.mu-display-math {
display: block;
width: 100%;

text-align: center;
}

.mu-hide.mu-ruby,
.mu-hide.mu-math {
width: auto;
Expand Down
10 changes: 10 additions & 0 deletions third_party/muya/src/block/base/__tests__/formatToggle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ describe('format.format() toggle-off with the caret inside the formatted run', (
expect(content.text).toBe('word');
});

it('display math: `$$word$$` removes both markers without shifting the selection', () => {
const content = selectInFirstBlock(bootMuya('$$word$$\n'), 2, 6);
const setCursor = vi.spyOn(content, 'setCursor');

content.format('inline_math');

expect(content.text).toBe('word');
expect(setCursor).toHaveBeenLastCalledWith(0, 4, true);
});

it('u (html_tag): `<u>word</u>` removes the underline tags', () => {
// `format('u')` matches the html_tag token whose tag === 'u'.
const content = caretInFirstBlock(bootMuya('<u>word</u>\n'), 2);
Expand Down
7 changes: 6 additions & 1 deletion third_party/muya/src/block/base/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,12 @@ function getOffset(offset: number, token: Token) {
case 'inline_code':

case 'inline_math': {
const markerLen = type === 'strong' || type === 'del' ? 2 : 1;
const markerLen
= type === 'strong' || type === 'del'
? 2
: type === 'inline_math'
? token.marker.length
: 1;
return markeredOffset(dis, len, markerLen, markerLen);
}

Expand Down
11 changes: 11 additions & 0 deletions third_party/muya/src/clipboard/__tests__/pasteBlockMerge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ describe('paste — paragraph merges inline into a non-empty text block (A3)', (
});
});

describe('paste — same-line display math (#4904)', () => {
it('renders pasted double-dollar math without rewriting its markdown', async () => {
const source = '$$ E=MC^2 $$';
const muya = bootMuya('');
const block = contentBlocks(muya)[0];

expect(await paste(muya, block, 0, 0, source)).toBe(`${source}\n`);
expect(muya.domNode.querySelector('.katex-display')).not.toBeNull();
});
});

describe('paste — multi-line paragraph into a heading keeps only the first line (A6)', () => {
it('only the first soft-line lands in the heading, the rest become a paragraph', async () => {
const muya = bootMuya('# Title\n');
Expand Down
1 change: 1 addition & 0 deletions third_party/muya/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export const CLASS_NAMES = genUpper2LowerKeyHash([
'MU_LINK_IN_BRACKET',
'MU_LIST_ITEM',
'MU_LOOSE_LIST_ITEM',
'MU_DISPLAY_MATH',
'MU_MATH',
'MU_MATH_TEXT',
'MU_MATH_RENDER',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom

import type { CodeEmojiMathToken } from '../types';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Muya } from '../../muya';
import { tokenizer } from '../lexer';
import { execInlineDisplayMath } from '../rules';

const bootedHosts: HTMLElement[] = [];

beforeEach(() => {
window.MUYA_VERSION = 'test';
});

afterEach(() => {
while (bootedHosts.length)
bootedHosts.pop()!.remove();
document.getSelection()?.removeAllRanges();
});

function bootMuya(markdown: string): Muya {
const host = document.createElement('div');
document.body.appendChild(host);
const muya = new Muya(host, { markdown } as ConstructorParameters<typeof Muya>[1]);
muya.init();
bootedHosts.push(muya.domNode);
return muya;
}

function mathTokens(src: string): CodeEmojiMathToken[] {
return tokenizer(src).filter(
(token): token is CodeEmojiMathToken => token.type === 'inline_math',
);
}

describe('same-line display math (#4904)', () => {
it('tokenizes double-dollar formulas without requiring separate fence lines', () => {
const [token] = mathTokens('Before $$ E=MC^2 $$ after');

expect(token.marker).toBe('$$');
expect(token.content).toBe(' E=MC^2 ');
});

it('does not interpret an empty $$$$ delimiter pair as math', () => {
expect(mathTokens('$$$$')).toHaveLength(0);
});

it('allows a single dollar inside double-dollar math', () => {
const [token] = mathTokens('$$x$y$$');

expect(token.content).toBe('x$y');
});

it('tokenizes adjacent display formulas independently', () => {
const tokens = mathTokens('$$a$$$$b$$');

expect(tokens.map(token => [token.marker, token.content])).toEqual([
['$$', 'a'],
['$$', 'b'],
]);
});

it('leaves multiline double-dollar syntax to the block parser', () => {
expect(mathTokens('$$a\nb$$')).toHaveLength(0);
});

it('falls back to single-dollar math when a double-dollar opener is unclosed', () => {
const [token] = mathTokens('$$x$');

expect(token.marker).toBe('$');
expect(token.content).toBe('x');
});

it('preserves soft line breaks inside single-dollar math', () => {
const [token] = mathTokens('$a\nb$');

expect(token.content).toBe('a\nb');
});

it('renders double-dollar formulas in display mode and single-dollar formulas inline', () => {
const muya = bootMuya('$x$ and $$x$$\n');
const previews = muya.domNode.querySelectorAll('.mu-math-render');
const wrappers = muya.domNode.querySelectorAll('.mu-math');

expect(previews).toHaveLength(2);
expect(previews[0].querySelector('.katex-display')).toBeNull();
expect(previews[1].querySelector('.katex-display')).not.toBeNull();
expect(wrappers[0].classList.contains('mu-display-math')).toBe(false);
expect(wrappers[1].classList.contains('mu-display-math')).toBe(true);
});

it('uses the full double-dollar marker when mapping preview offsets', () => {
const muya = bootMuya('$$x$$\n');
const preview = muya.domNode.querySelector<HTMLElement>('.mu-math-render');

expect(preview?.dataset.start).toBe('2');
expect(preview?.dataset.end).toBe('3');
});

it('preserves the authored same-line syntax when serializing markdown', () => {
const source = '$$ E=MC^2 $$\n';
const muya = bootMuya(source);

expect(muya.getMarkdown()).toBe(source);
});
});

describe('same-line display math — escape handling and adversarial input', () => {
it('lets an escaped closer extend the content to the next closer', () => {
const [token] = mathTokens('$$a\\$$b$$');

expect(token.marker).toBe('$$');
expect(token.content).toBe('a\\$$b');
});

it('treats an even backslash run before the closer as escaped-backslash content', () => {
const [token] = mathTokens('$$a\\\\$$');

expect(token.content).toBe('a\\\\');
});

it('does not match when the only closer is escaped', () => {
expect(execInlineDisplayMath('$$a\\$$')).toBeNull();
});

it('does not match a dangling backslash or a backslash before a newline', () => {
expect(execInlineDisplayMath('$$a\\')).toBeNull();
expect(execInlineDisplayMath('$$a\\\n$$')).toBeNull();
});

// The replaced regex backtracked quadratically here: 131,072 backslashes
// blocked Node for ~16 seconds. The scanner must stay linear.
it('handles adversarial backslash runs in linear time', () => {
const src = `$$${'\\'.repeat(131072)}$`;

const started = performance.now();
expect(execInlineDisplayMath(src)).toBeNull();
const tokens = mathTokens(src);
const elapsed = performance.now() - started;

// The full tokenizer may still match the tail as single-dollar math,
// but never as display math.
expect(tokens.every(token => token.marker === '$')).toBe(true);
expect(elapsed).toBeLessThan(500);
});
});
12 changes: 10 additions & 2 deletions third_party/muya/src/inlineRenderer/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
} from './types';
import escapeCharactersMap from '../config/escapeCharacter';
import { isLengthEven, union } from '../utils';
import { beginRules, inlineRules, linkValidateRules, validateRules } from './rules';
import { beginRules, execInlineDisplayMath, inlineRules, linkValidateRules, validateRules } from './rules';
import {
correctUrl,
getAttributes,
Expand Down Expand Up @@ -195,7 +195,15 @@ function tryChunks(state: ILexState): boolean {
const chunks = ['inline_code', 'del', 'emoji', 'inline_math'] as const;

for (const rule of chunks) {
const to = state.inlineRules[rule].exec(state.src);
let to: RegExpExecArray | null;
if (rule === 'inline_math') {
to
= execInlineDisplayMath(state.src)
?? state.inlineRules[rule].exec(state.src);
}
else {
to = state.inlineRules[rule].exec(state.src);
}
if (to && isLengthEven(to[3])) {
if (rule === 'emoji') {
// An emoji opener must sit at a word boundary: a ":" glued to a
Expand Down
20 changes: 10 additions & 10 deletions third_party/muya/src/inlineRenderer/renderer/inlineMath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ export default function inlineMath(this: Renderer, {
}: ISyntaxRenderOptions & { token: CodeEmojiMathToken }) {
const className = this.getClassName(outerClass, block, token, cursor);
const { i18n } = this.muya;
const mathSelector
const { start, end } = token.range;
const { marker } = token;
const displayMode = marker.length === 2;
let mathSelector
= className === CLASS_NAMES.MU_HIDE
? `span.${className}.${CLASS_NAMES.MU_MATH}`
: `span.${CLASS_NAMES.MU_MATH}`;

const { start, end } = token.range;
const { marker } = token;
if (displayMode)
mathSelector += `.${CLASS_NAMES.MU_DISPLAY_MATH}`;

const startMarker = this.highlight(
h,
Expand All @@ -44,12 +46,10 @@ export default function inlineMath(this: Renderer, {

const { loadMathMap } = this;

const displayMode = false;
const key = `${math}_${type}`;
const key = JSON.stringify([math, type, displayMode]);
let mathVnode = null;
let previewSelector = `span.${CLASS_NAMES.MU_MATH_RENDER}`;
// Inline math errors stay compact to keep the surrounding text baseline
// (#4100, inline-math-align); surface the parse reason via the title.
// Single-dollar errors stay compact to preserve the surrounding text baseline (#4100).
let errorTitle = '';
if (loadMathMap.has(key)) {
mathVnode = loadMathMap.get(key);
Expand Down Expand Up @@ -86,8 +86,8 @@ export default function inlineMath(this: Renderer, {
? { contenteditable: 'false', title: errorTitle }
: { contenteditable: 'false' },
dataset: {
start: String(start + 1), // '$'.length
end: String(end - 1), // '$'.length
start: String(start + marker.length),
end: String(end - marker.length),
},
},
mathVnode,
Expand Down
49 changes: 49 additions & 0 deletions third_party/muya/src/inlineRenderer/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,55 @@ export const gfmRules = {

export type GfmRules = typeof gfmRules;

/**
* Single-line `$$...$$` display math. Semantically this is
* `/^(\$\$)(?!\$)((?:(?!\1)[^\\\n]|\\.)+)(\\*)\1/`, but that regex backtracks
* quadratically on `$$` followed by a long backslash run (the `\\.` content
* pairs overlap the `(\\*)` trailing group), which can freeze tokenization
* for seconds on a crafted line. This scanner is the linear-time equivalent:
* content ends at the first unescaped same-line `$$`, a backslash always
* escapes the following character (the regex's greedy pair-first behavior,
* so an escaped `$` extends the content past that closer), and a returned
* match therefore always has an unescaped closer — the escape-parity group
* (`to[3]`) is always empty and passes the lexer's `isLengthEven` check.
*/
export function execInlineDisplayMath(src: string): RegExpExecArray | null {
if (!src.startsWith('$$') || src[2] === '$')
return null;

const { length } = src;
let i = 2;
while (i < length) {
const char = src[i];
if (char === '\n')
return null;

if (char === '$' && src[i + 1] === '$') {
const content = src.slice(2, i);
const match = [
`$$${content}$$`,
'$$',
content,
'',
] as string[] as RegExpExecArray;
match.index = 0;
match.input = src;
return match;
}

if (char === '\\') {
if (i + 1 >= length || src[i + 1] === '\n')
return null;
i += 2;
}
else {
i += 1;
}
}

return null;
}

// Markdown extensions (not belongs to GFM and Commonmark)
export const inlineExtensionRules = {
// eslint-disable-next-line regexp/no-super-linear-backtracking
Expand Down
Loading