Architecture

LavaTex is split into two independent concerns that don’t know about each other: turning a note into a .tex file (export), and making custom math commands render live while you write (preview). A third, smaller piece collects labels for the reference-picker command. All of the actual “understand LaTeX” work is delegated to pandoc, pandoc-crossref, and a small Lua filter — LavaTex’s own code is pure text transforms plus some Obsidian glue.

Source layout

latex-exporter/src/
  latex/                        pure functions, no Obsidian API
    theoremEnvironments.ts        frontmatter -> {trigger word: env id}
    theoremBlockPreprocessor.ts   bold-statement block -> pandoc fenced div
    rawEnvironments.ts            $$\begin{align}...\end{align}$$ -> {=latex} raw block
    referenceShortcuts.ts         [#label] -> raw \ref{label} span
    inlineMacros.ts                \newcommand in body {=latex} blocks -> text
    labelCollector.ts             note text -> every {#label} + context
    markdownNormalizer.ts         strip ∎/{=latex} noise for the plain-markdown export
  obsidian-math/
    mathJaxMacros.ts             feed macro text to Obsidian's live MathJax renderer
  export/
    paths.ts                     vault/plugin filesystem paths
    pathEnv.ts                   PATH augmentation shared by pandoc.ts/latex.ts
    frontmatterRefs.ts           resolve [[wikilink]] frontmatter fields to referenced notes
    citations.ts                 &[[Source]] -> raw \cite{key} span + assembled bibliography-raw
    markdownCitations.ts         &[[Source]] -> readable [marker] + bibliography (markdown export)
    pandoc.ts                    spawn pandoc with the fixed flag set (LaTeX export only)
    latex.ts                     spawn latexmk to compile PDF, open it
    exporter.ts                  orchestrates the preprocessing passes + pandoc/latexmk/markdown
  ui/
    ReferenceSuggestModal.ts     fuzzy picker for the reference-insertion commands
    PromptModal.ts               single-line text prompt (raw LaTeX, equation/theorem labels)
  main.ts                        wires commands + events to the pieces above

Everything in latex/ is text-in/data-out with no side effects, which is what makes each transform independently testable and lets exporter.ts stay a short, readable pipeline instead of a place where unrelated concerns pile up.

Export: note → .tex

flowchart LR
    Note["note.md\n(bold statements, macros:,\n$$\begin{align}...\end{align}$$, [#label])"]
    Note --> FmRefs["resolveFrontmatterReferences\n([[wikilink]] preamble/macros/\nbibliography/theorems -> inlined)"]
    FmRefs --> Cite["expandCitations\n(&[[Source]] -> \cite{key},\nbibitem: -> assembled bibliography-raw)"]
    Cite --> Align["convertAlignBlocksToRaw"]
    Align --> Blocks["preprocessTheoremBlocks\n(frontmatter theorems: -> env ids)"]
    Blocks --> Refs["expandReferenceShortcuts"]
    Refs --> Tmp[("temp .md")]

    Tmp --> Pandoc(["pandoc"])
    Template["pandoc/template.latex"] --> Pandoc
    Lua["pandoc/theorems.lua\n(Lua filter)"] --> Pandoc
    Crossref["pandoc-crossref\n(external filter)"] --> Pandoc

    Pandoc --> Tex["note.tex"]
    Tex -.->|pdflatex / xelatex, by hand| PDF[("PDF")]

Each preprocessing step targets disjoint syntax (align blocks, bold headers, reference shortcuts), so their order mostly doesn’t matter — they’re run in this sequence in exporter.ts for no reason deeper than readability.

theorems.lua is the one piece doing real LaTeX-environment work (::: {.lemma title="..." #id} → \begin{lemma}[...]\label{id}); theoremBlockPreprocessor.ts’s only job is producing the fenced-div syntax that filter understands. Two small single-purpose transforms instead of one that does both jobs.

Pandoc never reparses a fenced div’s attribute values as markdown — they’re plain strings — so a title containing a raw-inline span (like a citation’s `\cite{key}`{=latex}) would otherwise land in the .tex literally, backticks and all. theorems.lua round-trips the title string through pandoc.read/pandoc.write before splicing it into \begin{env}[title], so it’s rendered exactly like ordinary body text would be.

Numbered, cross-referenceable equations ($$...$$ {#eq:foo}, [@eq:foo]) are handled entirely by pandoc-crossref — a well-maintained, purpose-built tool for exactly that, not reimplemented here.

Export: note → plain markdown

“Export plain Markdown” doesn’t call pandoc at all — unlike every other export target. An earlier version did (skipping just --template/ --lua-filter), but pandoc’s markdown writer round-trips this plugin’s pandoc-facing syntax faithfully: fenced divs, raw `...`{=latex} spans, doubly-escaped attribute strings. That’s precisely the problem for a plain-markdown export — none of it is plain.

flowchart LR
    Note["note.md"]
    Note --> FmRefs["resolveFrontmatterReferences\n(only macros: matters here)"]
    FmRefs --> Cite["expandCitationsForMarkdown\n(&[[Source]] -> [marker],\nbibitem: -> bibliography[])"]
    Cite --> Unwrap["unwrapRawLatexSpans\n(strip `...`{=latex} wrapper)"]
    Unwrap --> Strip["stripEnvironmentEndMarks\n(remove ∎)"]
    Strip --> Assemble["trim frontmatter to title/author/date,\nprepend macros: as $$...$$,\nappend ## Bibliography"]
    Assemble --> Out[("note.exported.md")]

Bold-statement headers, $$...$$ math, and \begin{align} are left exactly as the note already has them — already valid, readable markdown, nothing to transform. [#label]/\ref{} theorem cross-references are left alone too: amsthm’s numbering only exists after a LaTeX compile, and there’s no compiler in this path to produce it — an honest gap, not a bug.

Live preview: math macros in Obsidian’s own editor

Obsidian’s MathJax renderer has no idea what a note’s custom \newcommands mean — normally it only ever sees them inside the exported .tex, compiled by real LaTeX. LavaTex mirrors macro definitions into MathJax’s own live macro table so they render immediately in edit/preview mode too:

flowchart LR
    FM["frontmatter\nmacros: field"]
    Body["body {=latex} blocks\n(a note-local helper macro,\ne.g. proof-only)"]

    FM --> Extract1["(read directly)"]
    Body --> Extract2["extractInlineMacros"]

    Extract1 --> Combine["concatenate"]
    Extract2 --> Combine

    Combine --> Register["registerMathMacros"]
    Register --> Tex2chtml["MathJax.tex2chtml(macroSource)"]
    Tex2chtml --> Live["Live rendering in\nObsidian edit/preview"]

This runs on plugin load and whenever the active note or its frontmatter changes (main.ts listens to workspace.on("active-leaf-change") and metadataCache.on("changed")).

The registration technique matters and was the result of an actual bug: mutating MathJax.tex.macros directly is a dead end — that object is only consulted once, at MathJax’s own startup, so changing it afterwards is a silent no-op. Running the macro text through MathJax’s own parser via tex2chtml is what actually works, because \newcommand execution is a real side effect of that parse. This is the same technique the community Extended MathJax plugin uses for its preamble.sty feature.

Not every macro can render this way: \pa in the worked example uses \rotatebox (a graphicx package macro), which MathJax’s TeX engine simply doesn’t implement — no such extension exists for MathJax. It renders correctly in the exported PDF (real LaTeX) but shows as an undefined-command error in Obsidian’s preview. Accepted, on purpose: LavaTex’s preview stays MathJax-only rather than shelling out to a local LaTeX install to render one symbol.

Inserting a reference

sequenceDiagram
    participant U as User
    participant Cmd as "Insert reference to label" command
    participant LC as labelCollector
    participant Modal as ReferenceSuggestModal
    participant Ed as Editor

    U->>Cmd: run command
    Cmd->>LC: collectLabels(noteText)
    LC-->>Cmd: LabelEntry[] (label + context)
    Cmd->>Modal: open(entries)
    U->>Modal: fuzzy search, select
    Modal->>Ed: replaceSelection("[#label]")

collectLabels scans for every {#label} attribute in the note — a bold-statement theorem header and a labelled $$...$$ {#eq:foo} equation use the identical syntax, so one scan finds both kinds, and the picker doesn’t need to know which is which.

“Reference equation” is the same flow, filtered to label.startsWith("eq:") and with ReferenceSuggestModal’s format callback swapped to `[@${label}]` instead of the default `[#${label}]` — pandoc- crossref’s syntax, which renders as \eqref{} instead of plain \ref{}.

“Add equation label” and “Add theorem label” don’t need any of this — they’re a PromptModal (a single-line text prompt, also backing “Insert raw LaTeX”) that inserts {#eq:name} / {#name} directly at the cursor.