` 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.