MathJax 3 in Jekyll

EDIT, 8/18/20: A recent Kramdown update has rendered some of this post obsolete. For up-to-date instructions on preventing line breaks between inline MathJax 3 equations and immediately adjacent punctuation, see this post.


I’ve blogged before about serving MathJax 2 conditionally in Jekyll. It’s the same for MathJax 3—just link to the appropriate file (the tex-chtml component suffices for my purposes).

MathJax 3 in Kramdown

Jekyll uses Kramdown by default, and Kramdown comes with native support for MathJax that uses $$ as delimiters (for both inline and display math). The inline-math blocks get rendered as <script type="math/tex"> tags, and the display-math blocks get rendered as <script type="math/tex; mode=display"> tags.

Unfortunately, support for those <script> tags has been dropped in the new MathJax 3! The link there has a snippet of code that solves the problem (to be run before MathJax), though if you use it with Kramdown you’ll have to replace 'script[type^="text/tex"]' with 'script[type^="math/tex"]'.

Keep Inline Math on the Same Line as Immediately Adjacent Punctuation

One thing that LaTeX does that MathJax doesn’t is keep inline math on the same line as immediately adjacent punctuation. Browsers are perfectly happy to insert a line break between MathJax and a comma, period, colon, parenthesis, or dash, for example. This isn’t often a problem, but it’s more common than you might think, and when it strikes it’s a real eyesore! It’s also hard to “diagnose,” since it depends on screen width and browser, which are variable.

While implementing the Kramdown solution mentioned above, I took the opportunity to solve this line-break problem for myself once and for all. Here is the code, which I use on this blog in conjunction with a .no-wrap: { white-space: nowrap; } style rule:

const getInlineReplacement = (node, text) => {
  const [prevSib, nextSib] = ['previousSibling', 'nextSibling'].map(prop => node[prop]);
  const [prevIsText, nextIsText] = [prevSib, nextSib].map(sib => sib && sib.nodeType === Node.TEXT_NODE);
  const prevChar = prevIsText ? prevSib.textContent.slice(-1) : '';
  const nextChar = nextIsText ? nextSib.textContent[0] : '';
  const [keepPrevChar, keepNextChar] = [prevChar, nextChar].map(char => /\S/.test(char));

  if (!keepPrevChar && !keepNextChar) return text;

  const span = document.createElement('span');
  span.classList.add('no-wrap');

  if (keepPrevChar) {
    prevSib.textContent = prevSib.textContent.slice(0, -1);
    span.appendChild(document.createTextNode(prevChar));
  }

  span.appendChild(text);

  if (keepNextChar) {
    nextSib.textContent = nextSib.textContent.slice(1);
    span.appendChild(document.createTextNode(nextChar));
  }

  return span;
};

window.MathJax = {
  options: {
    renderActions: {
      find: [10, doc => {
        for (const node of document.querySelectorAll('script[type^="math/tex"]')) {
          const display = !!node.type.match(/; *mode=display/);
          const text = document.createTextNode('');
          const math = new doc.options.MathItem(node.textContent, doc.inputJax[0], display);
          math.start = { node: text, delim: '', n: 0} ;
          math.end = { node: text, delim: '', n: 0 };
          doc.math.push(math);
          const replacement = display ? text : getInlineReplacement(node, text);
          node.parentNode.replaceChild(replacement, node);
        }
      }, '']
    }
  }
};

This code could certainly be adapated to non-Kramdown situations.

Anyway, the new MathJax 3 is more modular, lighter-weight, and faster to load than its predecessor, so I do recommend it. I’ve made good use of it in my post on covariant electrodynamics.