LaTeX equations for Lexxy (the rich text editor for Rails), rendered with MathJax.
- Toolbar button (and
Cmd/Ctrl+Shift+E) opens a dialog with a LaTeX input and live preview - Click any equation to edit it
- Inline (
e^{i\pi} + 1 = 0in a sentence) and display/block modes - Saved HTML stores the raw LaTeX in a
data-latexattribute, so content round-trips cleanly and is typeset again on display pages
Equations are stored in the document (and in what ActionText persists) as:
<span class="lexxy-math" data-latex="\frac{a}{b}">\frac{a}{b}</span> <!-- inline -->
<div class="lexxy-math" data-latex="\int_0^1 x\,dx">...</div> <!-- display/block -->Inside the editor the extension typesets these with the page's MathJax. On a display page, typesetMath (below) does the same thing without an editor. MathJax is host-provided, not bundled: load MathJax v4 (tex-chtml or tex-svg) on any page that uses the editor or displays saved content. The extension also works against MathJax v3, so hosts already loading v3 can upgrade on their own schedule.
This is the whole contract between the editor and anything else that needs to render or process saved equations — MathJax, but also any other script or server-side tool:
<span data-latex="...">is an inline equation,<div data-latex="...">is display/block. Tag name is the only signal for inline vs. display; treat it as authoritative.- Text content is the raw LaTeX, with no delimiters (
\(...\),$$...$$,\[...\]). data-latexis the one attribute this package relies on surviving sanitization (see the ActionText step below).class="lexxy-math"/lexxy-math--displayare presentation only, applied defensively bytypesetMath, and are not guaranteed to be present or to survive a sanitizer.
Vendor this package (or pin it) and pin lexxy's bare specifier so the extension resolves it:
# config/importmap.rb
pin "lexxy" # from the lexxy gem, per lexxy's install docs
pin "@37signals/lexxy", to: "lexxy.js" # alias used by @fnix/lexxy-mathjax
pin "@fnix/lexxy-mathjax", to: "lexxy-mathjax/index.js" # vendored src/ of this package
# ...pin the files under src/ as well if vendoring, e.g. via pin_all_fromnpm install @fnix/lexxy-mathjax @37signals/lexxyimport { configure } from "@37signals/lexxy"
import MathjaxExtension from "@fnix/lexxy-mathjax"
configure({
global: { extensions: [ MathjaxExtension ] }
})Include the stylesheet (@fnix/lexxy-mathjax/styles, or copy styles/lexxy-mathjax.css into your assets).
<script>
window.MathJax = { output: { displayAlign: "center" } }
</script>
<script defer src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-chtml.js"></script>If MathJax is missing, the editor still works and equations show their raw LaTeX.
This package persists raw LaTeX (data-latex) and re-typesets it on every load, so a few v4 changes are worth knowing about before upgrading a host that already has saved content:
- Different default font. v4 defaults to
mathjax-newcm(New Computer Modern), noticeably lighter than v3's TeX font. Setoutput: { font: "mathjax-tex" }to keep the old look. \text{...}is now macro-parsed. Thetextmacrosextension ships in all v4 combined components, so a backslash or brace inside\text{}that was inert in v3 can now raise "undefined control sequence" for existing saved equations.- Font-size macros changed in MathJax 4.1.2 (
\tiny/\Tinyswapped,\large…\Hugeshifted by one), so existing content can render at a different size. Opt out with thefontsizev3TeX package (loader: { load: ["[tex]/fontsizev3"] }, tex: { packages: { "[+]": ["fontsizev3"] } }) if you pin a floating@4range. - Accessibility moved, but only for math MathJax typesets itself. v4 turns assistive MathML off by default and turns the expression explorer (speech/braille) on instead. MathJax's own docs warn that math inserted via a direct conversion call —
tex2chtmlPromise/tex2svgPromise, which is what this package uses, both in the editor and on display pages — "will not become part of the list of math expressions that MathJax knows about in the page", so the explorer never attaches to it. BothcreateDOM(editor) andtypesetMath(display pages) compensate by settingrole="math"/aria-label="Equation: ..."on every equation directly, rather than relying on the explorer.
If your host has a Content Security Policy, two more v4 behaviours are worth knowing about — both are upstream MathJax behaviour, not something this package can change:
- The speech/braille explorer loads network resources on every page load, regardless of whether anything uses it:
sre/speech-worker.jsand three mathmap JSON files.options.enableSpeech,enableBrailleandenableEnrichmentdo not stop this — they only control whether speech is generated once those files have loaded. To actually stop the loading, setoptions.menuOptions.settings.enrich,.speechand.brailletofalse. - MathJax injects several
<style>elements (MJX-CHTML-styles,MJX-Menu-styles, others) that can't carry a CSP nonce — see mathjax/MathJax#2665.
The equation editor dialog (<lexxy-math-editor>) is styled entirely through CSS custom properties. Set any of these on :root or on an ancestor of the dialog to restyle it — a host that sets none gets exactly the defaults below, unchanged:
| Property | Default | Controls |
|---|---|---|
--lexxy-math-backdrop-color |
rgba(0, 0, 0, 0.35) |
::backdrop behind the modal |
--lexxy-math-dialog-bg |
Canvas |
dialog surface, button faces |
--lexxy-math-dialog-border-color |
#d4d4d8 |
dialog border |
--lexxy-math-dialog-radius |
0.5rem |
dialog corner radius |
--lexxy-math-dialog-shadow |
0 10px 30px rgba(0, 0, 0, 0.15) |
dialog drop shadow |
--lexxy-math-text-color |
CanvasText |
dialog and button text |
--lexxy-math-muted-color |
#52525b |
the "LaTeX" field label |
--lexxy-math-input-bg |
Field |
textarea background |
--lexxy-math-input-text-color |
FieldText |
textarea text |
--lexxy-math-input-border-color |
#d4d4d8 |
textarea, preview and button borders |
--lexxy-math-input-radius |
0.375rem |
textarea, preview and button radius |
--lexxy-math-focus-color |
#2563eb |
Save button fill, in-editor selection outline |
--lexxy-math-focus-content-color |
white |
Save button label |
--lexxy-math-error-color |
#b91c1c |
failed-equation text and dashed outline |
:root {
--lexxy-math-dialog-bg: var(--my-app-surface);
--lexxy-math-dialog-border-color: var(--my-app-border);
--lexxy-math-dialog-radius: var(--my-app-radius-lg);
--lexxy-math-focus-color: var(--my-app-accent);
}Canvas, CanvasText, Field and FieldText are the browser's own system colors: they track the page's light/dark scheme automatically, so the dialog follows the OS or prefers-color-scheme without any configuration.
--lexxy-math-backdrop-color targets ::backdrop, which only inherits custom properties from its originating element in newer browser engines. On older engines the backdrop falls back to today's rgba(0, 0, 0, 0.35) rather than breaking.
1. Allow data-latex through the server-side sanitizer (ActionText strips unknown attributes when rendering):
# config/initializers/lexxy_mathjax.rb
ActiveSupport.on_load(:action_text_content) do
ActionText::ContentHelper.allowed_attributes += [ "data-latex" ]
end2. Typeset saved content on display pages. Load MathJax (as above), include the stylesheet, and start the auto-typesetter:
// app/javascript/application.js
import "@fnix/lexxy-mathjax/styles"
import { startMathjaxAutoTypeset } from "@fnix/lexxy-mathjax/typeset"
startMathjaxAutoTypeset()@fnix/lexxy-mathjax/typeset is a separate entry point from the package's default export: it pulls in neither lexxy nor Lexical, so a display-only page doesn't load the editor. It re-typesets on DOMContentLoaded, turbo:load, turbo:frame-load and turbo:render, skips equations it has already rendered (so repeated Turbo navigations are cheap), and leaves any equation inside a live <lexxy-editor> to the extension.
For manual control — a specific container, a one-off re-render, or Stimulus — call typesetMath directly. It never throws and resolves with a summary:
import { typesetMath } from "@fnix/lexxy-mathjax/typeset"
const { total, typeset, failed, skipped, mathjax } = await typesetMath(document.querySelector(".post-body"))Options: output ("auto" | "chtml" | "svg", default "auto" — detects whether the host loaded tex-chtml or tex-svg), selector (default "span[data-latex], div[data-latex]" — matches only the persisted format above, never MathJax's own rendered output even if you widen this or pass force: true), force (re-typeset elements already rendered), accessible (default true, adds role="math" / aria-label).
typesetMath shares the same serialization queue as the editor's own typesetting (MathJax's conversion pipeline isn't reentrant), so concurrent calls — including the extra passes startMathjaxAutoTypeset can trigger around a Turbo navigation — run one at a time rather than racing. A call queued behind a large display-page pass waits for that pass to finish; in practice this only matters for the very first pass on a page, since a repeat pass over already-typeset content is cheap.
With Stimulus, for per-element control instead of a page-wide listener:
// app/javascript/controllers/math_controller.js
import { Controller } from "@hotwired/stimulus"
import { typesetMath } from "@fnix/lexxy-mathjax/typeset"
export default class extends Controller {
connect() {
typesetMath(this.element)
}
}<div data-controller="math"><%= @post.body %></div>Without importing this package's JS at all, wrap the stored LaTeX in MathJax's own delimiters and let typesetPromise scan for it normally:
document.querySelectorAll("[data-latex]").forEach((el) => {
const latex = el.getAttribute("data-latex")
el.textContent = el.tagName === "DIV" ? `\\[${latex}\\]` : `\\(${latex}\\)`
})
window.MathJax.typesetPromise()This is the only approach that keeps MathJax's contextual menu and the v4 expression explorer, because the math becomes part of MathJax's own document list (see the accessibility note above). The trade-offs: it depends on the host's tex.inlineMath / tex.displayMath configuration matching those delimiters, and invalid LaTeX renders as MathJax's own merror markup rather than this package's .lexxy-math--error styling.
An earlier version of this README suggested setting
el.textContentto the bare, undelimiteddata-latexvalue and then callingMathJax.typesetPromise([...elements]). That never worked:typesetPromisescans for delimiters, and bare LaTeX has none — passing an element list narrows where it scans, not whether it requires delimiters. If you copied that snippet, switch to one of the two approaches above.
Both approaches above need an actual MathJax running in a DOM — the editor's, or a display page's. Some PDF pipelines have neither: sghtmltopdf, for one, runs no JavaScript at all and does not render inline <svg>, so MathJax can never execute there, and CHTML output needs WOFF/WOFF2 web fonts, which such engines typically don't support either. The persisted format above is designed for exactly this case: data-latex carries everything a separate rendering step needs, without this package's help.
This isn't something this package ships — it's server-side, has no browser to run in, and every detail (page size, body font, cache store, job queue, PDF engine) belongs to the app, not the editor extension. The recipe:
1. Render LaTeX to SVG in Node, using MathJax's server-side v4 package, @mathjax/src@4 (mathjax-full is the v3 name, mathjax-node is v2-era and abandoned — most recipes found online are stale):
global.MathJax = {
loader: { paths: { mathjax: "@mathjax/src/bundle" }, load: [ "adaptors/liteDOM" ], require: (f) => import(f) },
svg: { fontCache: "none" }, // no shared <use> references — safer for a standalone image
options: { enableSpeech: false, enableBraille: false, enableEnrichment: false } // skip the speech-rule-engine; it's the dominant startup cost
}
await import("@mathjax/src/bundle/tex-svg.js")
await MathJax.startup.promise
const svg = await MathJax.tex2svgPromise(latex, { display, em, ex, containerWidth })
// pick em/containerWidth from the PDF's body font and page width — 16/780 above are editor-viewport defaults, not PDF ones
MathJax.done() // mandatory: shuts down the speech-rule-engine's worker threads, or the process never exits2. Transfer the geometry, then embed as an image. MathJax puts sizing on the <svg> element itself — output/svg.ts's createSVG sets width="Wex", height="Hex" and style="vertical-align:-Dex" — so wrapping it in an <img> (as most non-JS PDF engines require) discards all three unless you copy them onto the wrapper:
<img src="data:image/svg+xml;base64,…" alt="\frac{a}{b}"
style="width:2.62em;height:1.29em;vertical-align:-0.40em">3. Run this at export time, on a throwaway copy of the HTML — not at save time. Keep data-latex as the only thing that's ever persisted:
- stored content stays canonical and round-trips through the editor unchanged (an
<img>in a saved record wouldn't be recognized byimportDOM); - the sanitizer never has to accept
data:URIs onimg[src]app-wide, which would be a real XSS surface (data:image/svg+xmlcan carry<script>); - there's no staleness or backfill migration when MathJax's output changes — bump a version in your cache key and the next export re-renders.
If your PDF engine has no accessibility tagging (sghtmltopdf doesn't), alt never reaches the PDF's own text layer either — still worth setting for tools that read it another way, but don't rely on it for accessibility.
Since the transform depends on nothing this package controls, and the format contract is exactly the data-latex spec above, this is intentionally left as a recipe rather than shipped code.
Try it online: https://fnix.github.io/lexxy-mathjax/
Or serve the repo statically and open the demo locally:
npx serve .
# then visit http://localhost:3000/demo/npm install
npm testThe code follows lexxy's own conventions: plain ES modules, vanilla JS, no build step. Structure:
src/nodes/math_node.js—DecoratorNodestoring{ latex, display };createDOMtypesets with MathJax,exportDOM/importDOMhandle thedata-latexHTML formatsrc/extensions/mathjax_extension.js— theLexxy.Extension: registers the node, theinsertMathcommand, the toolbar button, and click-to-editsrc/elements/math_editor_dialog.js—<lexxy-math-editor>dialog with live previewsrc/helpers/mathjax_helper.js— serialized MathJax typesetting with graceful degradation; shared by the editor and bytypeset_math.jssrc/typeset_math.js—typesetMath(root), the display-page renderer; batches conversions and refreshes MathJax's stylesheet once per call instead of once per equationsrc/auto_typeset.js—startMathjaxAutoTypeset(), wirestypesetMathtoDOMContentLoaded/Turbo eventssrc/typeset.js— the@fnix/lexxy-mathjax/typesetentry point (re-exports the two above without pulling in lexxy/Lexical)styles/lexxy-mathjax.css— presentation for equations and the editor dialog; the--lexxy-math-*custom properties it exposes are a public contract (see Theming), covered bytest/styles.test.js