feat(prosemirror-markdown): math as $…$ / $$…$$ (#293 canon #6)

mathInline serializes as `$LaTeX$` and mathBlock as an own-line `$$\n<latex>\n$$`
fence (multi-line safe), closing hand-authoring gap A18. The LaTeX still lives in
node.attrs.text; a literal `$` inside it is escaped `\$`. On the raw-HTML path
(columns/cells) math keeps the schema-HTML `<span data-type="mathInline">` /
`<div data-type="mathBlock">` form (markdown is not re-parsed inside raw HTML) —
blockToHtml gets an explicit mathBlock case and inlineToHtml a mathInline case,
sharing the mathInlineHtml/mathBlockHtml helpers with the fallbacks so the two
forms cannot drift.

Parse: mathInlineExtension (inline) + mathBlockExtension (block) are added to the
SAME dedicated marked instance introduced for canon #7 (global singleton
untouched). The inline extension uses a currency-safe PANDOC rule: an opening `$`
must not be followed by whitespace, and the closing `$` must not be preceded by
whitespace nor followed by a digit — so `$5`, `$5 and $10`, `a $5 b $6 c`, `100$`
stay literal text while `$x^2$` is math. The block extension matches a `$$` fence
line and captures multi-line LaTeX non-greedily up to the next `$$` line.

The pandoc boundary rule lives ONCE in the new math-inline.ts
(INLINE_MATH_SOURCE) and is shared by the import tokenizer (^-anchored) and the
export prose escaper (global), so parse and serialize cannot disagree about what
is math. escapeProseMath (case "text", non-code runs only) escapes ONLY the two
delimiting `$` of a span the rule WOULD match, so a would-be-math prose span like
`the set $A$` re-imports as literal text while currency `$5 and $10` is emitted
CLEAN (zero backslash churn). marked decodes `\$`→`$` on re-parse, byte-stable.

Fallbacks to the lossless schema-HTML form (all documented + tested):
mathInline → <span> when empty / whitespace-edged / multi-line / pre-existing
`\$` / trailing `\` / immediately before a digit-text sibling (renderInlineChildren
guard, so `$…$5` can't lose the node); mathBlock → <div> when the LaTeX contains
`$$`. Each fallback round-trips losslessly and byte-stably.

Code safety (guards the canon #7 regression class): codeBlock reads raw child
text and inline `code` runs are excluded from escapeProseMath, so `$5`/`$x$` in
code stay literal with no math and no backslash corruption. ReDoS-checked on
adversarial 40k-char inputs (0–1 ms).

Tests: new math.test.ts (26 cases: serialize exactness, multi-line block, `\$`
escaping, currency ×5 asserting no `\$`, prose escape, columns schema-HTML,
inline-code/codeBlock safety, fail-open). Goldens in roundtrip / markdown-converter
flipped top-level math to `$…$`/`$$…$$`; the escapeAttr-idempotence golden wraps
math in a column (still exercises escapeAttr); columns/raw-HTML math assertions
unchanged.

package vitest: 585 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-07-04 09:37:37 +03:00
parent 77f5224b55
commit bfbd927866
7 changed files with 534 additions and 42 deletions
@@ -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 =>
`<span data-type="mathInline" data-katex="true" text="${escapeAttr(latex)}"></span>`;
const mathBlockHtml = (latex: string): string =>
`<div data-type="mathBlock" data-katex="true" text="${escapeAttr(latex)}"></div>`;
// #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 `<summary>${nodeContent.map(processNode).join("")}</summary>\n\n`;
return `<summary>${renderInlineChildren(nodeContent)}</summary>\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 `<span>`
// 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 `<span data-type="mathInline" data-katex="true" text="${escapeAttr(inlineMath)}"></span>`;
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<latex>\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 `<div>` form.
const blockMath = node.attrs?.text || "";
return `<div data-type="mathBlock" data-katex="true" text="${escapeAttr(blockMath)}"></div>`;
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 `<span>` 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 "<br>";
// #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 `<span>`
// 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 `<div>` 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":
@@ -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 `<span data-type="mathInline" data-katex="true" text="${escapeMathAttr(token.text)}"></span>`;
},
};
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 `<div data-type="mathBlock" data-katex="true" text="${escapeMathAttr(token.text)}"></div>`;
},
};
// 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
@@ -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
// (?<!\s) -> 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])+?)(?<!\\s)\\$(?![0-9])";
/** Global matcher for the export-side prose escaper. */
export const inlineMathGlobalRe = (): RegExp =>
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 `<span>` 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 `<span>` 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, "&amp;").replace(/"/g, "&quot;");
@@ -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. & -> &amp;, " -> &quot;.
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 `<div>` form.
const col = (child: any) => ({
type: 'columns',
content: [{ type: 'column', content: [child] }],
});
// & -> &amp;, " -> &quot; in the attribute context.
const once = c(col({ type: 'mathBlock', attrs: { text: 'a & "b"' } }));
expect(once).toContain(
'<div data-type="mathBlock" data-katex="true" text="a &amp; &quot;b&quot;"></div>',
);
// < 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('&lt;');
expect(angled).not.toContain('&gt;');
@@ -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(
'<span data-type="mathInline" data-katex="true" text="a < b"></span>',
);
// #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('&lt;');
expect(out).not.toContain('<span');
});
it('block math carries LaTeX in a text attr WITHOUT escaping < or >', () => {
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(
'<div data-type="mathBlock" data-katex="true" text="x > y &amp; z"></div>',
);
expect(out).not.toContain('&lt;');
expect(out).not.toContain('&gt;');
// #293 canon #6: `$$\n<latex>\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('&amp;');
expect(out).not.toContain('<div');
});
});
@@ -0,0 +1,262 @@
import { describe, expect, it } from 'vitest';
// Import DIRECTLY from src so we exercise the real converter pair (the parser
// lives in markdown-to-prosemirror.ts; importing it mutates the global DOM via
// jsdom at module load, which @tiptap/html's generateJSON needs under Node).
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
// ---------------------------------------------------------------------------
// #293 canon #6: math -> `$…$` (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 <span> 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('<span data-type="mathInline" data-katex="true" text=""></span>');
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('<span data-type="mathInline"');
expect(md1).not.toContain('$\\$100$');
expect(md2).toBe(md1);
expect(findNode(doc2, 'mathInline').attrs.text).toBe('\\$100');
});
it('mathInline immediately followed by a digit text run uses the span fallback (round-trips)', async () => {
// `$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('<span data-type="mathInline" data-katex="true" text="x^2"></span>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 <div> fallback', async () => {
const { md1, doc2, md2 } = await roundTrip({ type: 'mathBlock', attrs: { text: 'a $$ b' } });
expect(md1).toContain('<div data-type="mathBlock"');
expect(md1).not.toBe('$$\na $$ b\n$$');
expect(md2).toBe(md1);
expect(findNode(doc2, 'mathBlock').attrs.text).toBe('a $$ b');
});
});
describe('currency: a single/currency $ is NEVER math', () => {
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 <div> (no $$ fence), round-trips', async () => {
const { md1, doc2, md2 } = await roundTrip(
oneColumn({ type: 'mathBlock', attrs: { text: 'a^2+b^2' } }),
);
expect(md1).toContain('<div data-type="mathBlock" data-katex="true" text="a^2+b^2"></div>');
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('<div data-type="mathBlock" data-katex="true" text="a^2+b^2"></div>');
expect(md2).not.toContain('$$');
expect(findNode(doc2, 'mathBlock').attrs.text).toBe('a^2+b^2');
});
it('mathInline in a column paragraph emits <span> (no $…$), round-trips', async () => {
const { md1, doc2, md2 } = await roundTrip(
oneColumn(para(text('eq: '), { type: 'mathInline', attrs: { text: 'x_i' } })),
);
expect(md1).toContain('<span data-type="mathInline" data-katex="true" text="x_i"></span>');
expect(md1).not.toContain('$x_i$');
expect(md2).toContain('<span data-type="mathInline" data-katex="true" text="x_i"></span>');
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();
});
}
});
@@ -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(
'<div data-type="mathBlock" data-katex="true" text="a^2+b^2"></div>',
);
// #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(
'<span data-type="mathInline" data-katex="true" text="x_i"></span>',
);
// #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.