mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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|  | [c \\| d](http://e "f \\| g") |\n'
|
||||
)
|
||||
expect(once).toContain('')
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user