remark-math read "costs $5 a month, or $50" as a formula. A small remark plugin applies the Pandoc and Obsidian dollar rule so prices stay text and real math still renders.
Math support landed in GitBasedDocs on September 10, with remark-math finding the formulas and KaTeX drawing them on the server. Out of the box, that pair breaks prices. Hence the commit title: "KaTeX math, with prices kept as text".
What went wrong
Here is the sentence:
The plan costs $5 a month, or $50 a year.
With plain remark-math, the first $ opens a formula and the next $ closes it. Everything between them, 5 a month, or , goes to KaTeX as TeX. KaTeX drops the spaces and sets the letters in math italic. The reader gets 5amonth,or in a formula font, then 50 a year. as plain text. Both dollar signs are gone.
Client docs quote prices. A renderer that eats them is worse than no math at all.
The rule Obsidian and Pandoc use
Pandoc's manual spells out a rule for this, and Obsidian follows it too. Two checks decide whether a pair of single dollars is math:
- The text inside cannot start or end with a space.
- The closing
$cannot be followed by a digit.
$E = mc^2$ passes both. The pricing line fails both: the text inside ends with a space, and the closing $ sits right before 50. Writers never have to think about it.
remark-math has a switch, singleDollarTextMath: false, that turns single dollars off entirely. That fixes prices by breaking $x$, which is how Obsidian writers type inline math. Not an option.
The fix
A small remark plugin runs straight after remark-math and undoes the matches that break the rule:
function remarkDollarGuard() {
return (tree: MdRoot, file: { value: unknown }) => {
const src = String(file.value)
visit(tree, "inlineMath", (node, index, parent) => {
const start = node.position?.start.offset
const end = node.position?.end.offset
if (!parent || index === undefined || start === undefined || end === undefined) return
if (/^\s|\s$/.test(node.value) || /\d/.test(src[end] ?? "")) {
parent.children[index] = { type: "text", value: src.slice(start, end) }
}
})
}
}
It reads the original Markdown by offset. A rejected match becomes a text node holding the exact source slice, dollar signs included, so nothing the writer typed goes missing. KaTeX never sees it.
Checks
The renderer has a runnable check file, lib/render/markdown.check.ts. The math block covers the cases I care about:
const money = await r("The plan costs $5 a month, or $50 a year.")
assert.doesNotMatch(money, /katex/)
assert.match(money, /costs \$5 a month, or \$50 a year/)
assert.match(await r("Between $x$ and $y$."), /katex[\s\S]*katex/, "two real formulas still render")
Two more lines guard the rest. KaTeX runs with trust off, so \href{javascript:...} renders as an error and never as a link. And a broken formula like $\frac{1}{$ shows an error span while the rest of the page still renders.
The smaller bits
KaTeX renders to HTML and MathML on the server, so readers load no math script, and its CSS is imported on the doc page route only. A long display equation scrolls sideways inside its own box instead of pushing the page wider than a phone screen. Fenced ```math blocks render as display math too, since GitHub supports that form.