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);