diff --git a/packages/prosemirror-markdown/src/lib/markdown-converter.ts b/packages/prosemirror-markdown/src/lib/markdown-converter.ts index 8c35440c..0011801d 100644 --- a/packages/prosemirror-markdown/src/lib/markdown-converter.ts +++ b/packages/prosemirror-markdown/src/lib/markdown-converter.ts @@ -3,6 +3,11 @@ import { attachedCommentFor, standaloneCommentFor, } from "./attached-comment.js"; +import { + encodeInlineMathLatex, + inlineMathGlobalRe, + inlineMathSerializable, +} from "./math-inline.js"; import { attachmentToHtml, audioToHtml, @@ -91,6 +96,27 @@ export function convertProseMirrorToMarkdown(content: any): string { const escapeLinkText = (value: unknown): string => String(value ?? "").replace(/[\\`*_~[\]<&!()]/g, (c: string) => `\\${c}`); + // #293 canon #6: the schema-HTML forms for math. These are the LOSSLESS forms + // the raw-HTML path (columns/cells) and the mathInline fallback emit, and the + // SAME shape the importer's schema parseHTML rebuilds (span/div carrying the + // LaTeX in a `text="…"` attribute). Kept as small helpers so the readable + // `$…$`/`$$…$$` markdown forms and the raw-HTML forms cannot drift apart. + const mathInlineHtml = (latex: string): string => + ``; + const mathBlockHtml = (latex: string): string => + `
`; + + // #293 canon #6: neutralize a would-be inline-math `$…$` span sitting in PROSE + // text so it re-imports as literal text (never a phantom math node). We escape + // ONLY the two delimiting `$` of a span the inline-math tokenizer WOULD match + // (shared rule, math-inline.ts), leaving the inner untouched. A currency `$` + // (`$5`, `$5 and $10`) has no VALID closing under the rule, so it never + // matches and is emitted CLEAN (no backslash churn). On re-import marked's + // escape tokenizer turns each `\$` back into a literal `$`, so `the set $A$` + // round-trips as text while `$5 and $10` stays exactly as written. + const escapeProseMath = (value: string): string => + value.replace(inlineMathGlobalRe(), (_m, inner) => `\\$${inner}\\$`); + // Recursion depth guard. processNode is mutually recursive (directly and via // processListItem/processTaskItem/blockToHtml), and a pathologically nested // document (e.g. tens of thousands of nested blockquotes) would otherwise @@ -172,7 +198,7 @@ export function convertProseMirrorToMarkdown(content: any): string { return nodeContent.map(processNode).join("\n\n"); case "paragraph": { - const text = nodeContent.map(processNode).join(""); + const text = renderInlineChildren(nodeContent); const align = node.attrs?.textAlign; // Non-default alignment round-trips as an ATTACHED HTML comment at the // END of the block line (#293 canon #9): @@ -192,7 +218,7 @@ export function convertProseMirrorToMarkdown(content: any): string { case "heading": { const level = node.attrs?.level || 1; - const headingText = nodeContent.map(processNode).join(""); + const headingText = renderInlineChildren(nodeContent); const headingLine = "#".repeat(level) + " " + headingText; const headingAlign = node.attrs?.textAlign; // A non-default heading alignment attaches the same trailing comment @@ -226,6 +252,12 @@ export function convertProseMirrorToMarkdown(content: any): string { // marks loop, so they are never escaped; only the run's inner text is. if (!(node.marks || []).some((m: any) => m.type === "code")) { textContent = textContent.replace(/==/g, "\\=\\="); + // #293 canon #6: escape a would-be inline-math `$…$` span so it stays + // literal text on re-import (currency `$5` is left clean — see + // escapeProseMath). Runs on the SAME non-code runs as the `==` escape + // above; an inline `code` run returns verbatim below, matching the + // codeBlock path (a `$…$` inside code must stay code, never math). + textContent = escapeProseMath(textContent); } // Apply marks (bold, italic, code, etc.) if (node.marks) { @@ -598,26 +630,39 @@ export function convertProseMirrorToMarkdown(content: any): string { } case "detailsSummary": - return `${nodeContent.map(processNode).join("")}\n\n`; + return `${renderInlineChildren(nodeContent)}\n\n`; case "detailsContent": return `${nodeContent.map(processNode).join("\n")}\n`; case "mathInline": { - // The schema's `text` attribute has no parseHTML, so TipTap's default - // parser reads it from the `text` HTML attribute (NOT the element's text - // content). Emit span[data-type="mathInline"] carrying the LaTeX in a - // `text="..."` attribute so it round-trips. marked cannot parse $...$ - // back, so the previous form was lossy. + // #293 canon #6: inline math serializes as Obsidian-native `$LaTeX$` + // (readable, re-parsed by the importer's marked inline extension). A + // literal `$` inside the LaTeX is escaped `\$` so it cannot close the + // span early (the importer decodes `\$`→`$`). When the LaTeX cannot be + // safely fenced (empty, whitespace-edged, multi-line, or an ambiguous + // backslash-before-`$`), fall back to the LOSSLESS schema-HTML `` + // form (inlineMathSerializable). A following-sibling digit — which would + // also break the pandoc closing rule — is handled by renderInlineChildren + // (this case cannot see siblings). const inlineMath = node.attrs?.text || ""; - return ``; + if (!inlineMathSerializable(inlineMath)) { + return mathInlineHtml(inlineMath); + } + return `$${encodeInlineMathLatex(inlineMath)}$`; } case "mathBlock": { - // Same as mathInline: the LaTeX must ride in the `text` HTML attribute - // for the schema's default parser to recover it. + // #293 canon #6: block math serializes as a `$$` fence on its own lines + // (`$$\n\n$$`), so multi-line LaTeX is preserved. If the LaTeX + // itself contains a `$$` (which would close the fence early — essentially + // never valid inside a single math node), fall back to the lossless + // schema-HTML `
` form. const blockMath = node.attrs?.text || ""; - return `
`; + if (blockMath.includes("$$")) { + return mathBlockHtml(blockMath); + } + return `$$\n${blockMath}\n$$`; } case "mention": { @@ -871,6 +916,28 @@ export function convertProseMirrorToMarkdown(content: any): string { } }; + // Render a run of inline children to MARKDOWN, with the #293 canon #6 + // inline-math guard. A `mathInline` serialized as `$…$` whose FOLLOWING + // sibling renders starting with a DIGIT would put a digit right after the + // closing `$`, which the pandoc inline rule refuses to parse as math (the + // currency guard) — so the node would re-import as literal text (data loss). + // For that node ONLY we fall back to the lossless schema-HTML `` form. + // Every other inline node is rendered exactly as processNode would, so output + // is unchanged whenever no math sits directly before a digit. + const renderInlineChildren = (nodes: any[]): string => { + const parts = nodes.map(processNode); + for (let i = 0; i < nodes.length - 1; i++) { + if ( + nodes[i]?.type === "mathInline" && + parts[i].startsWith("$") && + /^[0-9]/.test(parts[i + 1] || "") + ) { + parts[i] = mathInlineHtml(nodes[i].attrs?.text || ""); + } + } + return parts.join(""); + }; + // Render inline content (text runs + their marks) to HTML. Used by the raw // HTML fallbacks (spanned tables, columns) where marked will NOT re-parse // markdown, so backtick/asterisk/bracket syntax would otherwise leak as @@ -880,8 +947,13 @@ export function convertProseMirrorToMarkdown(content: any): string { (inlineNodes || []) .map((n: any) => { if (n.type === "hardBreak") return "
"; + // #293 canon #6: on the raw-HTML path (columns/spanned cells) marked does + // NOT re-parse markdown, so inline math MUST stay the schema-HTML `` + // form here — a `$…$` fence would land as literal text on re-import. + if (n.type === "mathInline") return mathInlineHtml(n.attrs?.text || ""); if (n.type !== "text") { - // Inline atoms (mention, mathInline) already emit schema HTML. + // Other inline atoms (mention, status, footnoteRef) already emit + // schema HTML from processNode. return processNode(n); } let t = escapeHtmlText(n.text || ""); @@ -1125,11 +1197,18 @@ export function convertProseMirrorToMarkdown(content: any): string { return pageEmbedToHtml(block.attrs || {}); case "transclusionReference": return transclusionReferenceToHtml(block.attrs || {}); - // columns/column, math, htmlEmbed, footnotes, transclusionSource already - // emit schema-matching HTML from processNode. + // #293 canon #6: on the TOP-LEVEL path (processNode) mathBlock now + // serializes as a `$$…$$` fence, but marked does NOT re-parse markdown + // inside a raw-HTML block, so inside a column/cell it MUST stay the + // schema-HTML `
` form or it would land as literal `$$…$$` text on + // re-import. Give it an EXPLICIT case here (the same form the importer + // rebuilds) instead of delegating to processNode's fence form. + case "mathBlock": + return mathBlockHtml(block.attrs?.text || ""); + // columns/column, htmlEmbed, footnotes, transclusionSource already emit + // schema-matching HTML from processNode. case "columns": case "column": - case "mathBlock": case "htmlEmbed": case "footnotesList": case "footnoteDefinition": diff --git a/packages/prosemirror-markdown/src/lib/markdown-to-prosemirror.ts b/packages/prosemirror-markdown/src/lib/markdown-to-prosemirror.ts index 1c5af619..d9a80b46 100644 --- a/packages/prosemirror-markdown/src/lib/markdown-to-prosemirror.ts +++ b/packages/prosemirror-markdown/src/lib/markdown-to-prosemirror.ts @@ -13,6 +13,11 @@ import { Marked } from "marked"; import type { TokenizerExtension, RendererExtension } from "marked"; import { docmostExtensions } from "./docmost-schema.js"; import { parseAttachedComment } from "./attached-comment.js"; +import { + decodeInlineMathLatex, + escapeMathAttr, + inlineMathAnchoredRe, +} from "./math-inline.js"; import { attachmentToHtml, audioToHtml, @@ -79,11 +84,76 @@ const highlightMarkExtension: TokenizerExtension & RendererExtension = { }, }; +/** + * #293 canon #6: Obsidian-native math — `$LaTeX$` (inline) and `$$…$$` (block). + * + * INLINE `$…$` uses the SHARED pandoc currency-safe rule (math-inline.ts), the + * SAME rule the serializer's prose escaper uses, so currency (`$5`, + * `$5 and $10`) is NEVER math and a would-be-math prose `$x$` (escaped `\$x\$` + * on export) stays literal. The captured inner LaTeX is decoded (`\$`→`$`) and + * emitted as the schema's `span[data-type="mathInline"]` carrying the LaTeX in a + * `text="…"` attribute (the schema's default attribute parser reads it back). + * + * BLOCK `$$…$$` matches a `$$` fence on its own line(s), capturing multi-line + * LaTeX up to the next `$$` line, and emits `div[data-type="mathBlock"]`. + * + * Both fail OPEN: an unbalanced `$`/`$$`, or a currency `$`, returns undefined + * from the tokenizer and stays literal text with no crash. Registered on the + * SAME dedicated instance as the highlight extension (never the global marked + * singleton), so the `$`/`$$` behavior cannot leak into unrelated callers. + */ +const mathInlineExtension: TokenizerExtension & RendererExtension = { + name: "mathInline", + level: "inline", + start(src: string) { + const i = src.indexOf("$"); + return i < 0 ? undefined : i; + }, + tokenizer(src: string) { + const match = inlineMathAnchoredRe().exec(src); + if (!match) return undefined; // currency / unbalanced -> literal + return { + type: "mathInline", + raw: match[0], + text: decodeInlineMathLatex(match[1]), + } as any; + }, + renderer(token: any) { + return ``; + }, +}; + +const mathBlockExtension: TokenizerExtension & RendererExtension = { + name: "mathBlock", + level: "block", + start(src: string) { + const m = /(?:^|\n)\$\$/.exec(src); + if (!m) return undefined; + return m.index + (m[0].startsWith("\n") ? 1 : 0); + }, + tokenizer(src: string) { + // A `$$` fence on its own line, then the SHORTEST run up to the next `$$` + // line (non-greedy, so it never swallows across an unrelated later fence). + // The inner may be empty (an empty mathBlock) or multi-line. + const match = /^\$\$[^\S\n]*\n([\s\S]*?)\n\$\$[^\S\n]*(?:\n|$)/.exec(src); + if (!match) return undefined; // no closing fence -> literal + return { + type: "mathBlock", + raw: match[0], + text: match[1], + } as any; + }, + renderer(token: any) { + return `
`; + }, +}; + // Dedicated marked instance: default (GFM) options plus the `==` highlight -// inline extension. Constructed once at module load so the extension is -// registered exactly once and never mutates the global `marked` singleton. +// inline extension and the `$…$` / `$$…$$` math extensions (#293 canon #6). +// Constructed once at module load so the extensions are registered exactly once +// and never mutate the global `marked` singleton. const markedInstance = new Marked().use({ - extensions: [highlightMarkExtension], + extensions: [highlightMarkExtension, mathInlineExtension, mathBlockExtension], }); // Setup DOM environment for Tiptap HTML parsing in Node.js diff --git a/packages/prosemirror-markdown/src/lib/math-inline.ts b/packages/prosemirror-markdown/src/lib/math-inline.ts new file mode 100644 index 00000000..4deb3621 --- /dev/null +++ b/packages/prosemirror-markdown/src/lib/math-inline.ts @@ -0,0 +1,78 @@ +/** + * Shared inline-math boundary rule (#293 canon #6). + * + * Pandoc's inline-math rule lives here because it is used in TWO directions that + * MUST agree byte-for-byte on which `$…$` spans are math: + * + * - the IMPORT tokenizer (markdown-to-prosemirror.ts) that turns `$LaTeX$` + * into a `mathInline` node, and + * - the EXPORT escaper (markdown-converter.ts) that backslash-escapes a + * would-be-math `$…$` span sitting in PROSE text so it re-imports as literal + * text instead of silently materializing a phantom math node. + * + * Defining the rule ONCE guarantees the two directions never drift: a span the + * tokenizer would match is EXACTLY a span the escaper neutralizes, so a prose + * `$x$` round-trips as literal text and math `$x^2$` round-trips as math. + * + * The rule (currency-safe, from pandoc): an opening `$` is NOT followed by + * whitespace; the closing `$` is NOT preceded by whitespace AND NOT immediately + * followed by a digit; the inner run is non-empty, single-line, and may embed an + * escaped `\$` (which never counts as the closer). Under this rule `$5`, + * `$5 and $10`, `price is $5`, `a $5 b $6 c` all stay literal (no VALID closing + * `$` exists — the `$` before a space-preceded amount fails the "not preceded by + * whitespace" test, and a lone `$` has no closer), while `$x^2$` is math. + */ + +// Core pattern (unanchored). Escaping note for the string form: +// \\$ -> a literal `$` +// (?!\s) -> opening `$` NOT followed by whitespace (also forces a +// non-empty inner: the next char must exist and be non-space) +// (?:\\\\\\$|[^$\n])+? -> inner: shortest run of either an escaped `\$` +// (consumed as a unit so it is never the closer) or any char +// that is neither an unescaped `$` nor a newline +// (? the char before the closing `$` is NOT whitespace +// \\$ -> closing `$` +// (?![0-9]) -> closing `$` NOT immediately followed by a digit (currency) +export const INLINE_MATH_SOURCE = + "\\$(?!\\s)((?:\\\\\\$|[^$\\n])+?)(? + new RegExp(INLINE_MATH_SOURCE, "g"); + +/** Anchored matcher for the import-side marked tokenizer. */ +export const inlineMathAnchoredRe = (): RegExp => + new RegExp("^" + INLINE_MATH_SOURCE); + +/** Decode a tokenizer-captured inner LaTeX: an escaped `\$` becomes `$`. */ +export const decodeInlineMathLatex = (inner: string): string => + inner.replace(/\\\$/g, "$"); + +/** Escape LaTeX for the `$…$` inline form so a literal `$` cannot close early. */ +export const encodeInlineMathLatex = (latex: string): string => + latex.replace(/\$/g, "\\$"); + +/** + * Whether a `mathInline` node's LaTeX can be safely serialized as `$LaTeX$` + * (vs. the always-lossless schema-HTML `` fallback). Requires: + * - non-empty (an empty span has no readable `$…$` form), + * - non-whitespace edges (pandoc's opening/closing whitespace rules), + * - single line (inline math never spans lines), + * - no pre-existing `\$` and no trailing `\` — either would make the + * `$`→`\$` escape ambiguous on decode (a `\\$` sequence, or an escaped + * closing `$`), so those rare cases take the `` fallback instead. + * NOTE: a following-sibling digit (which would also break the pandoc closing + * rule) cannot be seen from the node alone; that case is handled by the + * serializer's inline-children pass, not here. + */ +export const inlineMathSerializable = (latex: string): boolean => + latex.length > 0 && + !/^\s/.test(latex) && + !/\s$/.test(latex) && + !/[\r\n]/.test(latex) && + !latex.includes("\\$") && + !/\\$/.test(latex); + +/** Escape a value for an HTML double-quoted attribute (only & and " matter). */ +export const escapeMathAttr = (value: string): string => + value.replace(/&/g, "&").replace(/"/g, """); diff --git a/packages/prosemirror-markdown/test/markdown-converter-golden.test.ts b/packages/prosemirror-markdown/test/markdown-converter-golden.test.ts index adc4a7a5..86a00e25 100644 --- a/packages/prosemirror-markdown/test/markdown-converter-golden.test.ts +++ b/packages/prosemirror-markdown/test/markdown-converter-golden.test.ts @@ -184,13 +184,20 @@ describe('subpages token + unknown-in-container fallback', () => { describe('escaping idempotence (SPEC §11 phantom-diff guard)', () => { it('escapeAttr escapes ONLY & and " in an attribute context, and is idempotent', () => { - // The mathBlock `text` attr goes through escapeAttr. & -> &, " -> ". - const once = c({ type: 'mathBlock', attrs: { text: 'a & "b"' } }); - expect(once).toBe( + // #293 canon #6: a TOP-LEVEL mathBlock now serializes as a `$$` fence, so + // to exercise the schema-HTML `text` attr (which DOES go through escapeAttr) + // we wrap the math in a COLUMN — the raw-HTML path keeps the `
` form. + const col = (child: any) => ({ + type: 'columns', + content: [{ type: 'column', content: [child] }], + }); + // & -> &, " -> " in the attribute context. + const once = c(col({ type: 'mathBlock', attrs: { text: 'a & "b"' } })); + expect(once).toContain( '
', ); // < and > are deliberately NOT escaped (would accumulate on round-trips). - const angled = c({ type: 'mathBlock', attrs: { text: 'a < b > c' } }); + const angled = c(col({ type: 'mathBlock', attrs: { text: 'a < b > c' } })); expect(angled).toContain('text="a < b > c"'); expect(angled).not.toContain('<'); expect(angled).not.toContain('>'); diff --git a/packages/prosemirror-markdown/test/markdown-converter.test.ts b/packages/prosemirror-markdown/test/markdown-converter.test.ts index 5a8bddbb..2a39de6c 100644 --- a/packages/prosemirror-markdown/test/markdown-converter.test.ts +++ b/packages/prosemirror-markdown/test/markdown-converter.test.ts @@ -390,27 +390,26 @@ describe('convertProseMirrorToMarkdown', () => { // --------------------------------------------------------------------------- describe('math', () => { - it('inline math carries LaTeX in a text attr WITHOUT escaping < or >', () => { + it('inline math serializes as $LaTeX$ (Obsidian-native), no HTML escaping', () => { const out = convertProseMirrorToMarkdown( doc(para({ type: 'mathInline', attrs: { text: 'a < b' } })), ); - // < and > must NOT be HTML-escaped (idempotency); only & and " would be. - expect(out).toBe( - '', - ); + // #293 canon #6: readable `$…$` form; the LaTeX is verbatim (no HTML + // attribute escaping of < or & in the fence form). + expect(out).toBe('$a < b$'); expect(out).not.toContain('<'); + expect(out).not.toContain('', () => { + it('block math serializes as a $$ fence on its own lines', () => { const out = convertProseMirrorToMarkdown( doc({ type: 'mathBlock', attrs: { text: 'x > y & z' } }), ); - // & IS escaped (entity-significant), but < and > are NOT. - expect(out).toBe( - '
', - ); - expect(out).not.toContain('<'); - expect(out).not.toContain('>'); + // #293 canon #6: `$$\n\n$$`. The LaTeX is verbatim inside the fence + // (plain markdown, so & is NOT entity-escaped as it would be in an attr). + expect(out).toBe('$$\nx > y & z\n$$'); + expect(out).not.toContain('&'); + expect(out).not.toContain(' `$…$` (inline) and `$$…$$` (block). +// +// The CENTRAL correctness constraint is that a single/currency `$` is NEVER +// math (`$5`, `it costs $5 and $10` stay literal), and a would-be-math `$x$` +// span in PROSE round-trips as literal text (never a phantom math node). These +// tests pin the serialize forms, the pandoc currency rule, the low-churn prose +// escape, the columns/raw-HTML schema-HTML form, and codeBlock/inline-code +// safety, and assert byte-stable round-trips throughout. +// --------------------------------------------------------------------------- + +const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes }); +const text = (t: string, marks?: any[]) => + marks ? { type: 'text', text: t, marks } : { type: 'text', text: t }; +const para = (...inline: any[]) => ({ type: 'paragraph', content: inline }); + +// export -> import -> export. Returns md1, the re-imported doc, and md2 (which +// MUST equal md1 for the git-sync data path to be byte-stable). +async function roundTrip(node: any) { + const md1 = convertProseMirrorToMarkdown(doc(node)); + const doc2 = await markdownToProseMirror(md1); + const md2 = convertProseMirrorToMarkdown(doc2); + return { md1, doc2, md2 }; +} + +// Depth-first find the first node of a type in a re-imported doc. +function findNode(n: any, type: string): any { + if (!n || typeof n !== 'object') return undefined; + if (n.type === type) return n; + if (Array.isArray(n.content)) { + for (const c of n.content) { + const hit = findNode(c, type); + if (hit) return hit; + } + } + return undefined; +} + +// Concatenate every text run under a node (for asserting text is preserved). +function allText(n: any): string { + if (!n || typeof n !== 'object') return ''; + if (n.type === 'text') return n.text || ''; + if (Array.isArray(n.content)) return n.content.map(allText).join(''); + return ''; +} + +describe('mathInline serialize + round-trip', () => { + it('mathInline x^2 -> exact $x^2$ and re-imports as mathInline attrs.text x^2', async () => { + const { md1, doc2, md2 } = await roundTrip(para({ type: 'mathInline', attrs: { text: 'x^2' } })); + expect(md1).toBe('$x^2$'); + expect(md2).toBe(md1); // byte-stable + const math = findNode(doc2, 'mathInline'); + expect(math).toBeDefined(); + expect(math.attrs.text).toBe('x^2'); + // No stray literal text, no math-shaped currency false positive. + expect(allText(doc2)).toBe(''); + }); + + it('mathInline surrounded by prose round-trips as math (not currency)', async () => { + const { md1, doc2, md2 } = await roundTrip( + para(text('let '), { type: 'mathInline', attrs: { text: 'x^2' } }, text(' be')), + ); + expect(md1).toBe('let $x^2$ be'); + expect(md2).toBe(md1); + expect(findNode(doc2, 'mathInline').attrs.text).toBe('x^2'); + }); + + it('LaTeX containing a literal $ is escaped \\$ and round-trips exact', async () => { + const { md1, doc2, md2 } = await roundTrip(para({ type: 'mathInline', attrs: { text: 'a$b' } })); + expect(md1).toBe('$a\\$b$'); // inner $ escaped so it cannot close early + expect(md2).toBe(md1); + expect(findNode(doc2, 'mathInline').attrs.text).toBe('a$b'); + }); + + it('empty mathInline falls back to the lossless schema-HTML form', async () => { + const { md1, doc2, md2 } = await roundTrip(para({ type: 'mathInline', attrs: { text: '' } })); + // An empty `$$` would look like a block; the span form is lossless. + expect(md1).toBe(''); + expect(md2).toBe(md1); + expect(findNode(doc2, 'mathInline')).toBeDefined(); + }); + + it('mathInline whose LaTeX carries a pre-existing \\$ takes the span fallback', async () => { + // `\$` before escaping would make the `$`→`\$` escape ambiguous, so this + // rare case uses the always-lossless schema-HTML form (documented fork). + const { md1, doc2, md2 } = await roundTrip(para({ type: 'mathInline', attrs: { text: '\\$100' } })); + expect(md1).toContain(' { + // `$x^2$5` would fail the pandoc closing rule (digit after `$`), so the math + // node falls back to the lossless span form; the "5" stays literal text. + const { md1, doc2, md2 } = await roundTrip( + para({ type: 'mathInline', attrs: { text: 'x^2' } }, text('5')), + ); + expect(md1).toBe('5'); + expect(md1).not.toContain('$x^2$5'); + expect(md2).toBe(md1); + expect(findNode(doc2, 'mathInline').attrs.text).toBe('x^2'); + expect(allText(doc2)).toBe('5'); + }); +}); + +describe('mathBlock serialize + round-trip', () => { + it('multi-line mathBlock -> $$ fence with LaTeX intact, byte-stable', async () => { + const latex = '\\int_0^1 f\n= 1'; + const { md1, doc2, md2 } = await roundTrip({ type: 'mathBlock', attrs: { text: latex } }); + expect(md1).toBe('$$\n\\int_0^1 f\n= 1\n$$'); + expect(md2).toBe(md1); + const math = findNode(doc2, 'mathBlock'); + expect(math).toBeDefined(); + expect(math.attrs.text).toBe(latex); // multi-line preserved + }); + + it('single-line mathBlock round-trips', async () => { + const { md1, doc2, md2 } = await roundTrip({ type: 'mathBlock', attrs: { text: 'a^2+b^2' } }); + expect(md1).toBe('$$\na^2+b^2\n$$'); + expect(md2).toBe(md1); + expect(findNode(doc2, 'mathBlock').attrs.text).toBe('a^2+b^2'); + }); + + it('empty mathBlock round-trips as an empty $$ fence', async () => { + const { md1, doc2, md2 } = await roundTrip({ type: 'mathBlock', attrs: { text: '' } }); + expect(md1).toBe('$$\n\n$$'); + expect(md2).toBe(md1); + expect(findNode(doc2, 'mathBlock')).toBeDefined(); + }); + + it('mathBlock whose LaTeX contains a $$ takes the lossless
fallback', async () => { + const { md1, doc2, md2 } = await roundTrip({ type: 'mathBlock', attrs: { text: 'a $$ b' } }); + expect(md1).toContain('
{ + const cases = ['it costs $5', '$5 and $10', 'a $5 b $6 c', 'price is $5', 'pay $5 now']; + for (const original of cases) { + it(`"${original}" stays literal text with NO backslashes and NO math node`, async () => { + const { md1, doc2, md2 } = await roundTrip(para(text(original))); + // Emitted markdown carries NO escaping (currency has no valid closing $). + expect(md1).toBe(original); + expect(md1).not.toContain('\\$'); + expect(md2).toBe(md1); + // No math node materialized; the text is preserved EXACTLY. + expect(findNode(doc2, 'mathInline')).toBeUndefined(); + expect(allText(doc2)).toBe(original); + }); + } + + it('a currency amount preserves the exact string across a round trip', async () => { + const { doc2 } = await roundTrip(para(text('$5 and $10'))); + expect(allText(doc2)).toBe('$5 and $10'); + expect(findNode(doc2, 'mathInline')).toBeUndefined(); + }); +}); + +describe('prose $x$ (would-be math) round-trips as literal text (escaped)', () => { + it('the set $A$ -> \\$A\\$ and re-imports as literal text, no math node', async () => { + const { md1, doc2, md2 } = await roundTrip(para(text('the set $A$ is closed'))); + expect(md1).toBe('the set \\$A\\$ is closed'); + expect(md2).toBe(md1); // byte-stable + expect(findNode(doc2, 'mathInline')).toBeUndefined(); + // The literal text is preserved exactly (backslashes are a serialization + // detail, decoded back on import). + expect(allText(doc2)).toBe('the set $A$ is closed'); + }); +}); + +describe('math inside a column keeps the schema-HTML form (NOT $…$)', () => { + const oneColumn = (child: any) => ({ + type: 'columns', + content: [{ type: 'column', content: [child] }], + }); + + it('mathBlock in a column emits
(no $$ fence), round-trips', async () => { + const { md1, doc2, md2 } = await roundTrip( + oneColumn({ type: 'mathBlock', attrs: { text: 'a^2+b^2' } }), + ); + expect(md1).toContain('
'); + expect(md1).not.toContain('$$'); + // The schema-HTML math form survives the round trip (a re-imported column + // gains a default data-layout, so we assert the math div, not full equality). + expect(md2).toContain('
'); + expect(md2).not.toContain('$$'); + expect(findNode(doc2, 'mathBlock').attrs.text).toBe('a^2+b^2'); + }); + + it('mathInline in a column paragraph emits (no $…$), round-trips', async () => { + const { md1, doc2, md2 } = await roundTrip( + oneColumn(para(text('eq: '), { type: 'mathInline', attrs: { text: 'x_i' } })), + ); + expect(md1).toContain(''); + expect(md1).not.toContain('$x_i$'); + expect(md2).toContain(''); + expect(md2).not.toContain('$x_i$'); + expect(findNode(doc2, 'mathInline').attrs.text).toBe('x_i'); + }); +}); + +describe('code is never math (canon #7 codeBlock regression class)', () => { + it('inline `code` span containing $x$ / $5 stays code, no math, no backslashes', async () => { + const { md1, doc2, md2 } = await roundTrip( + para(text('$x$ and $5', [{ type: 'code' }])), + ); + // A code run is emitted verbatim in a backtick span — no `$` escaping, no math. + expect(md1).toBe('`$x$ and $5`'); + expect(md1).not.toContain('\\$'); + expect(md2).toBe(md1); + expect(findNode(doc2, 'mathInline')).toBeUndefined(); + const codeRun = findNode(doc2, 'text'); + expect(codeRun.marks?.some((m: any) => m.type === 'code')).toBe(true); + expect(codeRun.text).toBe('$x$ and $5'); + }); + + it('codeBlock containing $…$ and $5 stays code, no math, no backslash corruption', async () => { + const code = 'cost = $5\nx = $y$'; + const { md1, doc2, md2 } = await roundTrip({ + type: 'codeBlock', + attrs: { language: 'python' }, + content: [text(code)], + }); + // Fenced code is literal: the `$` are verbatim, no escaping, no math node. + expect(md1).toContain('cost = $5'); + expect(md1).toContain('x = $y$'); + expect(md1).not.toContain('\\$'); + expect(md2).toBe(md1); + expect(findNode(doc2, 'mathInline')).toBeUndefined(); + expect(findNode(doc2, 'mathBlock')).toBeUndefined(); + // The `$` are preserved verbatim inside the fence (marked re-adds one + // trailing newline the exporter strips again, so compare against that). + const codeText = allText(findNode(doc2, 'codeBlock')); + expect(codeText).toContain('cost = $5'); + expect(codeText).toContain('x = $y$'); + expect(codeText).not.toContain('\\$'); + }); +}); + +describe('fail-open: unbalanced / lone $ never crashes and stays literal', () => { + for (const src of ['$', '$$', 'a $ b', '$ x $', 'unbalanced $x here']) { + it(`"${src}" imports without crash and materializes no math node`, async () => { + const doc2 = await markdownToProseMirror(src); + expect(doc2).toBeDefined(); + expect(findNode(doc2, 'mathInline')).toBeUndefined(); + // `$$` alone would only ever be a fence with content; a lone `$$` line is + // not a valid fence, so no mathBlock either. + expect(findNode(doc2, 'mathBlock')).toBeUndefined(); + }); + } +}); diff --git a/packages/prosemirror-markdown/test/roundtrip.test.ts b/packages/prosemirror-markdown/test/roundtrip.test.ts index 62db34b3..3132f904 100644 --- a/packages/prosemirror-markdown/test/roundtrip.test.ts +++ b/packages/prosemirror-markdown/test/roundtrip.test.ts @@ -60,10 +60,8 @@ describe('math round-trip (mathBlock + mathInline)', () => { const source = { type: 'mathBlock', attrs: { text: 'a^2+b^2' } }; const { md1, doc2, md2 } = await roundTrip(source); - // One-way emit: LaTeX rides in the `text` HTML attribute, data-katex flag set. - expect(md1).toBe( - '
', - ); + // #293 canon #6: block math emits a `$$` fence on its own lines. + expect(md1).toBe('$$\na^2+b^2\n$$'); // Byte-stable: the second export reproduces the first exactly. expect(md2).toBe(md1); @@ -81,9 +79,8 @@ describe('math round-trip (mathBlock + mathInline)', () => { const source = para({ type: 'mathInline', attrs: { text: 'x_i' } }); const { md1, doc2, md2 } = await roundTrip(source); - expect(md1).toBe( - '', - ); + // #293 canon #6: inline math emits the Obsidian-native `$LaTeX$` form. + expect(md1).toBe('$x_i$'); expect(md2).toBe(md1); // The re-imported paragraph's child is a mathInline with the LaTeX recovered.