diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js
index 989b3566..9a81e6d5 100644
--- a/desktop-app/resources/js/preview-worker.js
+++ b/desktop-app/resources/js/preview-worker.js
@@ -25,19 +25,24 @@ const markedOptions = {
const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m;
const BLOCK_MATH_PATTERN = /^\$\$[ \t]*\n?([\s\S]*?)\n?\$\$[ \t]*(?:\n|$)/;
+const INLINE_MATH_START_PATTERN = /\\(?:\$|\(|\[)|\$/;
const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/;
const SUPERSCRIPT_PATTERN = /^\^(?!\s)([^^\n]*?\S)\^(?!\^)/;
const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/;
const HIGHLIGHT_PATTERN = /^==(?=\S)([\s\S]*?\S)==/;
-const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d+\.\s+|>\s+)/;
+const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d{1,9}[.)]\s+|>\s*)/;
+const DEFINITION_LIST_DISALLOWED_TERM_PATTERN = /^(?: {4}|\t|[ \t]{0,3}(?:`{3,}|~{3,}|#{1,6}(?:[ \t]+|$)|(?:[*_-][ \t]*){3,}$|[=-]+[ \t]*$|\[[^\]\n]+\]:[ \t]*\S|',
+ '',
+ 'Visible content.',
+ '',
+ filler
+ ].join('\n'));
+
+ await expect(page.locator('#markdown-preview .preview-render-block')).toHaveCount(0);
+ await expect(page.locator('#markdown-preview')).not.toContainText('hidden first');
+ await expect(page.locator('#markdown-preview')).not.toContainText('hidden second');
+ await expect(page.locator('#markdown-preview')).toContainText('Visible content.');
+});
+
+test('keeps GFM lists separated when the bullet character changes', async ({ page }) => {
+ await setEditorContent(page, [
+ '- List 1, item 1',
+ '- List 1, item 2',
+ '* List 2, item 1',
+ '',
+ '+ Same marker, item 1',
+ '+ Same marker, item 2'
+ ].join('\n'));
+
+ const lists = page.locator('#markdown-preview > ul');
+ await expect(lists).toHaveCount(3);
+ await expect(lists.nth(0).locator('li')).toHaveCount(2);
+ await expect(lists.nth(1).locator('li')).toHaveCount(1);
+ await expect(lists.nth(2).locator('li')).toHaveCount(2);
+});
+
+test('distinguishes currency, escaped dollars, and valid inline math', async ({ page }) => {
+ await setEditorContent(page, [
+ 'Options ran $20, $45, and $99 for the three tiers.',
+ '',
+ 'Escaped prices cost \\$20, \\$45, and \\$99.',
+ '',
+ 'Before text $x_1^2$ then $x_2^2$ after text.',
+ '',
+ 'A dollar inside math: $\\sqrt{\\$4}$.',
+ '',
+ 'Mixed price and math: $20 plus $x^2$.',
+ '',
+ 'Whitespace is literal: $ not math$ and $not math $.',
+ '',
+ 'Digit adjacency is literal: $x$5.'
+ ].join('\n'));
+
+ const paragraphs = page.locator('#markdown-preview > p');
+ await expect(paragraphs.nth(0)).toContainText('Options ran $20, $45, and $99 for the three tiers.');
+ await expect(paragraphs.nth(0).locator('.math-inline')).toHaveCount(0);
+ await expect(paragraphs.nth(0).locator('.math-literal-dollar')).toHaveCount(3);
+ await expect(paragraphs.nth(1)).toContainText('Escaped prices cost $20, $45, and $99.');
+ await expect(paragraphs.nth(1).locator('.math-inline')).toHaveCount(0);
+ await expect(paragraphs.nth(1).locator('.math-literal-dollar')).toHaveCount(3);
+ await expect(page.locator('#markdown-preview .math-inline')).toHaveCount(4);
+ await expect(page.locator('#markdown-preview .math-inline').nth(0)).toHaveText('$x_1^2$');
+ await expect(page.locator('#markdown-preview .math-inline').nth(1)).toHaveText('$x_2^2$');
+ await expect(page.locator('#markdown-preview .math-inline').nth(2)).toHaveText('$\\sqrt{\\$4}$');
+ await expect(page.locator('#markdown-preview .math-inline').nth(3)).toHaveText('$x^2$');
+ await expect(paragraphs.nth(4)).toContainText('Mixed price and math: $20 plus');
+ await expect(paragraphs.nth(4).locator('.math-literal-dollar')).toHaveCount(1);
+});
+
+test('does not transform dollars or footnotes inside code', async ({ page }) => {
+ await setEditorContent(page, [
+ '~~~md',
+ 'Options ran \\$20, \\$45, and \\$99.',
+ '[^fenced]: must stay code',
+ '~~~',
+ '',
+ 'Inline code: `[^inline]: must stay code` and `\\$20`.',
+ '',
+ 'Real note[^note] and repeated note[^note].',
+ '',
+ '[^note]: Footnote body.',
+ ' Continued body.'
+ ].join('\n'));
+
+ await expect(page.locator('#markdown-preview pre code')).toHaveText([
+ 'Options ran \\$20, \\$45, and \\$99.',
+ '[^fenced]: must stay code'
+ ].join('\n'));
+ await expect(page.locator('#markdown-preview p code').nth(0)).toHaveText('[^inline]: must stay code');
+ await expect(page.locator('#markdown-preview p code').nth(1)).toHaveText('\\$20');
+ await expect(page.locator('#markdown-preview .footnote-ref')).toHaveCount(2);
+ await expect(page.locator('#fnref-note')).toHaveText('[1]');
+ await expect(page.locator('#fnref-note-2')).toHaveText('[1]');
+ await expect(page.locator('#markdown-preview .footnotes')).toContainText('Footnote body.');
+ await expect(page.locator('#markdown-preview .footnotes')).toContainText('Continued body.');
+});
+
+test('matches footnote labels case-insensitively and links every defined reference', async ({ page }) => {
+ await setEditorContent(page, [
+ 'Undefined[^missing].',
+ '',
+ 'Case reference[^Note].',
+ '',
+ 'Repeated[^repeat] and again[^REPEAT].',
+ '',
+ 'Slug collision[^a!] and another[^a?].',
+ '',
+ '[^note]: Case-insensitive definition.',
+ '[^repeat]: Repeated definition.',
+ '[^a!]: First collision definition.',
+ '[^a?]: Second collision definition.'
+ ].join('\n'));
+
+ await expect(page.locator('#markdown-preview > p').first()).toContainText('Undefined[^missing].');
+ await expect(page.locator('#markdown-preview .footnote-ref')).toHaveText(['[1]', '[2]', '[2]', '[3]', '[4]']);
+ await expect(page.locator('#markdown-preview .footnotes li')).toHaveCount(4);
+ await expect(page.locator('#fn-note')).toContainText('Case-insensitive definition.');
+ await expect(page.locator('#fn-repeat')).toContainText('Repeated definition.');
+ await expect(page.locator('#fn-a')).toContainText('First collision definition.');
+ await expect(page.locator('#fn-a-1')).toContainText('Second collision definition.');
+
+ const repeatedBackrefs = page.locator('#fn-repeat .footnote-backref');
+ await expect(repeatedBackrefs).toHaveCount(2);
+ await expect(repeatedBackrefs.nth(0)).toHaveAttribute('href', '#fnref-repeat');
+ await expect(repeatedBackrefs.nth(1)).toHaveAttribute('href', '#fnref-repeat-2');
+ await expect(repeatedBackrefs.nth(1)).toContainText('2');
+});
+
+test('supports Markdown Extra multi-term definition lists without changing ordinary prose', async ({ page }) => {
+ await setEditorContent(page, [
+ '~~~md',
+ 'Code term A',
+ 'Code term B',
+ ': Must stay literal code',
+ '~~~',
+ '',
+ 'Term A',
+ 'Term B',
+ ': Shared definition for both terms above',
+ ': Alternate shared definition',
+ '',
+ 'Single term',
+ ': Single definition',
+ '',
+ 'Ordinary line A',
+ 'Ordinary line B'
+ ].join('\n'));
+
+ const lists = page.locator('#markdown-preview dl');
+ await expect(lists).toHaveCount(2);
+ await expect(page.locator('#markdown-preview pre code')).toContainText(': Must stay literal code');
+ await expect(lists.nth(0).locator('dt')).toHaveText(['Term A', 'Term B']);
+ await expect(lists.nth(0).locator('dd')).toHaveText([
+ 'Shared definition for both terms above',
+ 'Alternate shared definition'
+ ]);
+ await expect(lists.nth(1).locator('dt')).toHaveText(['Single term']);
+ await expect(page.locator('#markdown-preview > p').last()).toContainText('Ordinary line A');
+ await expect(page.locator('#markdown-preview > p').last()).toContainText('Ordinary line B');
+});
+
+test('does not let definition lists preempt GFM and CommonMark block constructs', async ({ page }) => {
+ await setEditorContent(page, [
+ '1) Ordered item',
+ ': lazy continuation in the list',
+ '',
+ 'Setext heading',
+ '---',
+ ': paragraph after the heading',
+ '',
+ '***',
+ ': paragraph after the thematic break',
+ '',
+ ' indented code',
+ ': paragraph after the code',
+ '',
+ '[ref]: https://example.com',
+ ': paragraph after the reference definition',
+ '',
+ '[uses ref][ref]',
+ '',
+ 'A | B',
+ '--- | ---',
+ ': table row'
+ ].join('\n'));
+
+ await expect(page.locator('#markdown-preview dl')).toHaveCount(0);
+ await expect(page.locator('#markdown-preview ol')).toHaveCount(1);
+ await expect(page.locator('#markdown-preview ol')).toContainText(': lazy continuation in the list');
+ await expect(page.locator('#markdown-preview h2')).toHaveText('Setext heading');
+ await expect(page.locator('#markdown-preview hr')).toHaveCount(1);
+ await expect(page.locator('#markdown-preview pre code')).toHaveText('indented code\n');
+ await expect(page.locator('#markdown-preview a[href="https://example.com"]')).toHaveText('uses ref');
+ await expect(page.locator('#markdown-preview table')).toHaveCount(1);
+ await expect(page.locator('#markdown-preview table tbody td').first()).toHaveText(': table row');
+});
+
+test('keeps TeX atomic while preserving superscript, subscript, and highlight outside math', async ({ page }) => {
+ await setEditorContent(page, [
+ 'Outside ^two^, ~down~, and ==marked==.',
+ '',
+ 'Before text $x_1^2$ then $x_2^2$ after text.',
+ '',
+ 'Before text $right\\_ascension = 5h35m$ after text.',
+ '',
+ 'Before text $\\left\\{ x \\mid x > 0 \\right\\}$ after text.',
+ '',
+ '$A_cE\\left\\{-\\dfrac{du(x_1)}{dx},\\ \\dfrac{du(x_2)}{dx}\\right\\}$',
+ '',
+ 'Other delimiters: \\(x^2\\) and \\[y^2\\].',
+ '',
+ '$$',
+ 'A_cE\\left\\{-\\dfrac{du(x_1)}{dx},\\ \\dfrac{du(x_2)}{dx}\\right\\}',
+ '$$'
+ ].join('\n'));
+
+ await expect(page.locator('#markdown-preview')).toContainText('Outside two, down, and marked.');
+ await expect(page.locator('#markdown-preview > p sup')).toHaveText('two');
+ await expect(page.locator('#markdown-preview > p sub')).toHaveText('down');
+ await expect(page.locator('#markdown-preview > p mark')).toHaveText('marked');
+
+ const math = page.locator('#markdown-preview .math-inline');
+ await expect(math).toHaveCount(7);
+ await expect(math.nth(0)).toHaveText('$x_1^2$');
+ await expect(math.nth(1)).toHaveText('$x_2^2$');
+ await expect(math.nth(2)).toHaveText('$right\\_ascension = 5h35m$');
+ await expect(math.nth(3)).toHaveText('$\\left\\{ x \\mid x > 0 \\right\\}$');
+ await expect(math.nth(4)).toHaveText('$A_cE\\left\\{-\\dfrac{du(x_1)}{dx},\\ \\dfrac{du(x_2)}{dx}\\right\\}$');
+ await expect(math.nth(5)).toHaveText('\\(x^2\\)');
+ await expect(math.nth(6)).toHaveText('\\[y^2\\]');
+ await expect(page.locator('#markdown-preview .math-block')).toContainText('A_cE\\left\\{');
+});
+
+test('scopes legacy MathJax color syntax without changing standard switches or sanitization', async ({ page }) => {
+ await setEditorContent(page, [
+ '$\\color{red}{v\\^2} \\color{blue}{G}M\\left(\\color{green}{\\frac{2}{r}} - \\color{purple}{\\frac{1}{a}}\\right)$',
+ '',
+ '${\\color{red}v^2} + \\textcolor{blue}{G}M$',
+ '',
+ '$\\color{red}{outer \\color{blue}{inner} outer} + z$',
+ '',
+ '$\\color{orange} x + y$',
+ '',
+ '$$',
+ '\\color{teal}{A_{nested}}',
+ '$$',
+ '',
+ '```math',
+ '\\color{brown}{B^2}',
+ '```',
+ '',
+ '$x y$',
+ '',
+ 'Safe note[^safe].',
+ '',
+ '[^safe]:
body'
+ ].join('\n'));
+
+ const math = page.locator('#markdown-preview .math-inline');
+ await expect(math).toHaveCount(5);
+ await expect(math.nth(0)).toContainText('${\\color{red}v^2} {\\color{blue}G}M\\left({\\color{green}\\frac{2}{r}} - {\\color{purple}\\frac{1}{a}}\\right)$');
+ await expect(math.nth(0)).not.toContainText('\\^');
+ await expect(math.nth(1)).toContainText('{\\color{red}v^2}');
+ await expect(math.nth(2)).toContainText('${\\color{red}outer {\\color{blue}inner} outer} + z$');
+ await expect(math.nth(3)).toContainText('$\\color{orange} x + y$');
+ const mathBlocks = page.locator('#markdown-preview .math-block');
+ await expect(mathBlocks).toHaveCount(2);
+ await expect(mathBlocks.nth(0)).toContainText('{\\color{teal}A_{nested}}');
+ await expect(mathBlocks.nth(1)).toContainText('{\\color{brown}B^2}');
+ await expect(page.locator('#markdown-preview svg')).toHaveCount(0);
+ await expect(page.locator('#markdown-preview [onload], #markdown-preview [onerror]')).toHaveCount(0);
+ await expect.poll(() => page.evaluate(() => ({ math: window.__mathXss, note: window.__footnoteXss }))).toEqual({
+ math: undefined,
+ note: undefined
+ });
+});
diff --git a/tests/e2e/tab-split-sidebar-update.spec.js b/tests/e2e/tab-split-sidebar-update.spec.js
index 14246dc6..24173bbc 100644
--- a/tests/e2e/tab-split-sidebar-update.spec.js
+++ b/tests/e2e/tab-split-sidebar-update.spec.js
@@ -233,6 +233,7 @@ test('closing a tab keeps the document in Files and reopening restores the tab',
await page.locator('#tab-new-btn').click();
const closedTabId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id');
await setEditorContent(page, '# Kept document');
+ await expect.poll(async () => JSON.stringify(await storedDocuments(page))).toContain('# Kept document');
await page.locator('#tab-list .tab-item.active .tab-close-btn').click();
await expect(page.locator(`.tab-item[data-tab-id="${closedTabId}"]`)).toHaveCount(0);