Abdulkader Safi

The $5 problem

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.

3 min read

Share
A GitBasedDocs page where a price line renders as plain text next to real KaTeX math

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.

Last updated 10 Sep 2026 · filed under Typescript, frontend, obsidian

Every entry on this project

19 build notes, in order

Including the ones where nothing worked. You are on part 11.

Read the series →

FAQ

Frequently asked questions

Why does remark-math turn prices into formulas?

It pairs single dollar signs on a line, so in "costs $5 a month, or $50" everything between the two dollars goes to KaTeX as TeX. KaTeX drops the spaces and italicises the letters, and both dollar signs disappear from the page.

Can I still write inline math with single dollars?

Yes. `$E = mc^2$` and `$x$` render as math. A pair only falls back to text when the inside starts or ends with a space, or when the closing dollar is followed by a digit, which is the rule Pandoc and Obsidian use.

Written by

Abdulkader Safi

Software Engineer

Lead engineer at DSRPT, from Lebanon and based in Kuwait. I write about the tools and bugs from real client work, with the numbers I measured.

About me → GitHub LinkedIn

Need this kind of work done on your project?

Start a project →

Keep reading

More from GitBasedDocs

All 19 entries →
  • The GitBasedDocs search palette showing results for a partial word
    GitBasedDocs

    · part 19 of 19

    Search that quotes whatever you type

    GitBasedDocs search moved from LIKE over every page body to SQLite FTS5 kept in step by triggers. Word prefixes and accent folding came with the tokenizer. The real work was quoting input, choosing a stable order, and a backup detail about rowids.

  • A GitBasedDocs doc page with a Linked from list under the article
    GitBasedDocs

    · part 18 of 19

    Backlinks that use the renderer's own link rules

    GitBasedDocs pages now show which pages link to them, resolve Obsidian aliases, open embedded PDFs and list recent changes. The backlinks reuse the functions that turn links into hrefs, so a page appears under Linked from exactly when its link works.

  • A docker-compose.yml with BACKUP_ENABLED set to true and a folder of dated backup archives
    GitBasedDocs

    · part 17 of 19

    Backups behind one flag in docker compose

    GitBasedDocs now has a Dockerfile, a compose file and backups you turn on with BACKUP_ENABLED. Each archive is a VACUUM INTO copy plus the asset files, and the copy is edited before it is packed so a restored app catches up on its own.