diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 62bb6c9d7e..d1d248bbfd 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -162,6 +162,33 @@ it('renders display math while leaving ordinary currency alone', () => { assert.doesNotMatch(markup, /class="maka-math maka-math-inline"/); }); +it('keeps escaped brackets literal inside Markdown link labels', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: '[\\[DISCUSS\\] Clarify](https://example.com)', + }), + })); + + assert.match(markup, /]*href="https:\/\/example\.com"/); + assert.match(markup, /\[DISCUSS\] Clarify/); + assert.doesNotMatch(markup, /class="maka-math/); + assert.doesNotMatch(markup, /class="katex/); +}); + +it('still renders explicit inline math inside Markdown link labels', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: '[Value \\(x + 1\\)](https://example.com)', + }), + })); + + assert.match(markup, /]*href="https:\/\/example\.com"/); + assert.match(markup, /class="maka-math maka-math-inline"/); + assert.match(markup, /class="katex"/); +}); + it('does not treat shell variables, currency, or inline code as dollar-delimited math', () => { const markup = renderToStaticMarkup(createElement(MarkdownBody, { text: [ diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index 74f5e9f19c..01c894d14e 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -90,7 +90,12 @@ export const MARKDOWN_MATH_PLUGINS = [{ }, }] satisfies MarkdownInlinePlugin[]; -function protectMarkdownMath(source: string, startsAtLineStart = true): { +function protectMarkdownMath( + source: string, + startsAtLineStart = true, + protectEscapedBrackets = false, + detectLinkLabels = true, +): { text: string; safeSourceEnd: number; safeTextEnd: number; @@ -122,6 +127,38 @@ function protectMarkdownMath(source: string, startsAtLineStart = true): { continue; } + // Hide escaped label brackets from both the math delimiter scan and the + // Markdown bracket matcher, then restore them through the literal plugin. + if ( + protectEscapedBrackets + && source[index] === '\\' + && (source[index + 1] === '[' || source[index + 1] === ']') + ) { + text += transportToken(source[index + 1] ?? '', '2'); + index += 2; + atLineStart = false; + markSafe(); + continue; + } + + const linkLabel = detectLinkLabels + ? readMarkdownLinkLabel(source, index) + : undefined; + if (linkLabel?.kind === 'pending') { + text += source.slice(index); + break; + } + if (linkLabel?.kind === 'match') { + const labelSource = source.slice(index + 1, linkLabel.end - 1); + const protectedLabel = protectMarkdownMath(labelSource, false, true, false); + text += `[${protectedLabel.text}]`; + index = linkLabel.end; + atLineStart = false; + if (protectedLabel.safeSourceEnd === labelSource.length) markSafe(); + else canMarkSafe = false; + continue; + } + const literalToken = readLiteralToken(source, index); if (literalToken?.kind === 'pending') { text += source.slice(index); @@ -189,6 +226,49 @@ function protectMarkdownMath(source: string, startsAtLineStart = true): { return { text, safeSourceEnd, safeTextEnd }; } +function readMarkdownLinkLabel( + source: string, + index: number, +): + | { kind: 'match'; end: number } + | { kind: 'pending' } + | undefined { + if (source[index] !== '[') return undefined; + + let depth = 1; + let cursor = index + 1; + while (cursor < source.length) { + if (source[cursor] === '\\') { + cursor += Math.min(2, source.length - cursor); + continue; + } + if (source[cursor] === '`') { + let runEnd = cursor + 1; + while (source[runEnd] === '`') runEnd++; + const run = source.slice(cursor, runEnd); + const close = source.indexOf(run, runEnd); + if (close < 0) return { kind: 'pending' }; + cursor = close + run.length; + continue; + } + if (source[cursor] === '[') { + depth++; + cursor++; + continue; + } + if (source[cursor] === ']') { + depth--; + cursor++; + if (depth > 0) continue; + return source[cursor] === '(' || source[cursor] === '[' + ? { kind: 'match', end: cursor } + : undefined; + } + cursor++; + } + return { kind: 'pending' }; +} + function readFence( source: string, index: number,