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:
@@ -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, "&").replace(/"/g, """);
|
||||
@@ -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 `<div>` 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(
|
||||
'<div data-type="mathBlock" data-katex="true" text="a & "b""></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('<');
|
||||
expect(angled).not.toContain('>');
|
||||
|
||||
@@ -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('<');
|
||||
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 & z"></div>',
|
||||
);
|
||||
expect(out).not.toContain('<');
|
||||
expect(out).not.toContain('>');
|
||||
// #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('&');
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user