fix(editor): accept indented display math with inner dollars, escape pipes in cell attributes

Review comments on the previous commit:

- Display math whose body holds a `$` (`$$\n\$5\n$$`) fell back to prose
  because the body pattern refused every dollar; it now runs to the
  closing `$$`.
- Display math indented by spaces or a tab no longer opened, because the
  line-start check wanted `$$` right after the newline. The start pattern
  tolerates the indent, and points marked at the newline rather than the
  first `$` so the indent does not stay behind in the paragraph.
- `|` inside a link or image attribute in a table cell was still written
  bare, so the row split on the next load. The cell context now reaches
  destinations, titles and alt text as well as text.

# Conflicts:
#	src/renderer/src/components/editor/rich-markdown-extensions.ts
This commit is contained in:
averydev
2026-09-18 04:46:45 -07:00
committed by Neil
parent 9b54d56c69
commit ec0c651cfc
3 changed files with 70 additions and 28 deletions
@@ -735,6 +735,26 @@ describe('rich markdown round trip', () => {
expect(roundTripMarkdown('text\n\n$$\nx^2\n$$\n')).toBe('text\n\n$$\nx^2\n$$')
})
it('parses display math that is indented or holds an escaped dollar', () => {
for (const source of [
'text\n\n $$\nx^2\n$$\n',
'text\n $$\nx^2\n$$\n',
'text\n\t$$\nx^2\n$$\n'
]) {
expect(roundTripMarkdown(source)).toBe('text\n\n$$\nx^2\n$$')
}
expect(roundTripMarkdown('$$\n\\$5 + x\n$$\n')).toBe('$$\n\\$5 + x\n$$')
})
it('escapes pipes inside link and image attributes in table cells', () => {
const once = roundTripMarkdown(
'| a | b |\n|---|---|\n| ![x \\| y](img.png) | [c \\| d](http://e "f \\| g") |\n'
)
expect(once).toContain('![x \\| y](img.png)')
expect(once).toContain('[c \\| d](http://e "f \\| g")')
expect(roundTripMarkdown(`${once}\n`)).toBe(once)
})
it('preserves markdown tables', () => {
expect(roundTripMarkdown('| a | b |\n| - | - |\n| 1 | 2 |\n')).toContain('| a')
})
@@ -41,7 +41,6 @@ import { RichMarkdownCodeSpanPadding } from './rich-markdown-code-span-padding'
import { RichMarkdownListItem } from './rich-markdown-list-item'
import { RichMarkdownProseEntities } from './rich-markdown-prose-entities'
import { RichMarkdownParagraph } from './rich-markdown-paragraph'
import { RichMarkdownInlineMath } from './rich-markdown-inline-math'
import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight'
import { RichMarkdownEscapedCharacter } from './rich-markdown-escaped-character'
import { RichMarkdownSerializerFidelity } from './rich-markdown-serializer-fidelity'
@@ -56,18 +55,33 @@ const RichMarkdownLink = Link.extend({
priority: 90
})
// Why: marked ends a paragraph wherever a block tokenizer's `start` points, so
// display math only opens at a line start.
// Why: Pandoc's rule keeps money as text — both `$` must touch the formula, the closing one
// must not be followed by a digit, and an escaped `\$` never closes.
const INLINE_MATH_PATTERN = /^\$(?![\s$])((?:\\[\s\S]|[^$\\])*?)(?<!\s)\$(?!\d)/
const RichMarkdownInlineMath = InlineMath.extend({
markdownTokenizer: {
name: 'inlineMath', level: 'inline', start: (src: string) => src.indexOf('$'),
tokenize: (src: string) => { const match = src.match(INLINE_MATH_PATTERN); if (!match) return undefined; return { type: 'inlineMath', raw: match[0], latex: match[1] } }
}
})
const BLOCK_MATH_START_PATTERN = /\n[ \t]*\$\$/
const BLOCK_MATH_PATTERN = /^[ \t]*\$\$((?:(?!\$\$)[\s\S])+?)\$\$/
const RichMarkdownBlockMath = BlockMath.extend({
markdownTokenizer: {
name: 'blockMath', level: 'block',
start: (src: string) => BLOCK_MATH_START_PATTERN.exec(src)?.index ?? -1,
tokenize: (src: string) => { const match = src.match(BLOCK_MATH_PATTERN); if (!match) return undefined; return { type: 'blockMath', raw: match[0], latex: match[1].trim() } }
}
})
const RichMarkdownBlockMath = BlockMath.extend({
markdownTokenizer: {
name: 'blockMath',
level: 'block',
start: (src: string) => {
const index = src.indexOf('\n$$')
return index === -1 ? -1 : index + 1
},
// Why: marked cuts the paragraph at the returned index + 1; pointing at the newline keeps
// the indent out of the paragraph and lets the block tokenizer see the whole opener line.
start: (src: string) => BLOCK_MATH_START_PATTERN.exec(src)?.index ?? -1,
tokenize: (src: string) => {
const match = src.match(/^\$\$([^$]+)\$\$/)
const match = src.match(BLOCK_MATH_PATTERN)
if (!match) {
return undefined
}
@@ -35,7 +35,7 @@ function toSourceForm(node: JSONContent, context: SerializerContext): JSONConten
if (node.type === 'text') {
return textToSourceForm(node, context)
}
const next: JSONContent = node.type === 'image' ? imageToSourceForm(node) : node
const next: JSONContent = node.type === 'image' ? imageToSourceForm(node, context) : node
if (!next.content) {
return next
}
@@ -47,21 +47,20 @@ function toSourceForm(node: JSONContent, context: SerializerContext): JSONConten
}
function textToSourceForm(node: JSONContent, context: SerializerContext): JSONContent {
const marks = (node.marks ?? []).map(linkMarkToSourceForm)
const marks = (node.marks ?? []).map((mark) => linkMarkToSourceForm(mark, context))
const escaped = marks.some((mark) => mark.type === RICH_MARKDOWN_ESCAPED_CHARACTER_MARK)
const insideCode = marks.some((mark) => mark.type === 'code')
let text = node.text ?? ''
if (escaped) {
text = escapedCharacterSourceText(text, insideCode)
} else if (context.insideTableCell) {
// Why: marked splits cells on unescaped `|` before inline lexing, code spans included.
text = text.replace(/\|/g, '\\|')
}
const text = escaped
? escapedCharacterSourceText(node.text ?? '', insideCode)
: escapeTableCellPipes(node.text ?? '', context)
const kept = marks.filter((mark) => mark.type !== RICH_MARKDOWN_ESCAPED_CHARACTER_MARK)
return kept.length > 0 ? { ...node, text, marks: kept } : { type: 'text', text }
}
function linkMarkToSourceForm(mark: NonNullable<JSONContent['marks']>[number]) {
function linkMarkToSourceForm(
mark: NonNullable<JSONContent['marks']>[number],
context: SerializerContext
) {
if (mark.type !== 'link' || !mark.attrs) {
return mark
}
@@ -69,13 +68,13 @@ function linkMarkToSourceForm(mark: NonNullable<JSONContent['marks']>[number]) {
...mark,
attrs: {
...mark.attrs,
href: destinationToSourceForm(mark.attrs.href),
title: titleToSourceForm(mark.attrs.title)
href: destinationToSourceForm(mark.attrs.href, context),
title: titleToSourceForm(mark.attrs.title, context)
}
}
}
function imageToSourceForm(node: JSONContent): JSONContent {
function imageToSourceForm(node: JSONContent, context: SerializerContext): JSONContent {
if (!node.attrs) {
return node
}
@@ -83,22 +82,31 @@ function imageToSourceForm(node: JSONContent): JSONContent {
...node,
attrs: {
...node.attrs,
src: destinationToSourceForm(node.attrs.src),
src: destinationToSourceForm(node.attrs.src, context),
alt:
typeof node.attrs.alt === 'string'
? node.attrs.alt.replace(/[\\[\]]/g, '\\$&')
? escapeTableCellPipes(node.attrs.alt.replace(/[\\[\]]/g, '\\$&'), context)
: node.attrs.alt,
title: titleToSourceForm(node.attrs.title)
title: titleToSourceForm(node.attrs.title, context)
}
}
}
// Why: a bare destination ends at an unbalanced `)`; marked unescapes `\(` and `\)` back on load.
// (The `<…>` form is not an option here: Orca's raw-HTML pass would placeholder it before parsing.)
function destinationToSourceForm(destination: unknown): unknown {
return typeof destination === 'string' ? destination.replace(/[()]/g, '\\$&') : destination
function destinationToSourceForm(destination: unknown, context: SerializerContext): unknown {
return typeof destination === 'string'
? escapeTableCellPipes(destination.replace(/[()]/g, '\\$&'), context)
: destination
}
function titleToSourceForm(title: unknown): unknown {
return typeof title === 'string' ? title.replace(/"/g, '\\"') : title
function titleToSourceForm(title: unknown, context: SerializerContext): unknown {
return typeof title === 'string'
? escapeTableCellPipes(title.replace(/"/g, '\\"'), context)
: title
}
// Why: marked splits cells on unescaped `|` before parsing anything inside them, attributes included.
function escapeTableCellPipes(text: string, context: SerializerContext): string {
return context.insideTableCell ? text.replace(/\|/g, '\\|') : text
}