File size: 3,489 Bytes
da5cba1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// Math in prompts, titles and task lists ($$…$$, \[…\], \(…\), $…$), typeset with KaTeX, which loads on the first page
// that has some. A "$…$" pair is math only when what's between looks like TeX (a backslash, ^, _ or braces, or a lone
// variable), so "spent $300 and set aside $200" stays prose. Code, preformatted text and anything marked .no-math are
// left alone; KaTeX runs untrusted (no \href, no \html*), and a formula it can't read stays as its source.

const KATEX = "https://cdn.jsdelivr.net/npm/katex@0.19.0/dist/";
const SRI = {
  js: "sha384-QFFtAGzvvj+bfgCGxXJlNZZR1nXEZgvG8tDLCCY1F19xl20WlfTYgguB4VcNdxYk",
  css: "sha384-3rdsX6e5mueWyoweR9NIVmtEsUkokpBT/0ALqKKIBMr9j4qhHkaIkAcGgsE6uVlp",
};
const RX = /\$\$([\s\S]{1,4000}?)\$\$|\\\[([\s\S]{1,4000}?)\\\]|\\\(([\s\S]{1,1000}?)\\\)|\$([^$\n]{1,400}?)\$/g;
const texy = (s) => /[\\^_{}]/.test(s) || /^\s*[A-Za-z]\s*$/.test(s);
const SKIP = "code, pre, textarea, script, style, .katex, .no-math, .fv, .runbox";
let loading = null;

function load() {
  if (window.katex) return Promise.resolve();
  if (loading) return loading;
  const css = Object.assign(document.createElement("link"), { rel: "stylesheet", href: KATEX + "katex.min.css", integrity: SRI.css, crossOrigin: "anonymous" });
  document.head.append(css);
  loading = new Promise((resolve, reject) => {
    const s = Object.assign(document.createElement("script"), { src: KATEX + "katex.min.js", integrity: SRI.js, crossOrigin: "anonymous", async: true });
    s.onload = () => resolve();
    s.onerror = () => { loading = null; reject(new Error("KaTeX didn't load")); };
    document.head.append(s);
  });
  return loading;
}

function* formulas(text) {
  RX.lastIndex = 0;
  for (const m of text.matchAll(RX)) {
    const [whole, dd, br, pa, d] = m;
    if (d != null && !texy(d)) continue;
    yield { at: m.index, whole, tex: dd ?? br ?? pa ?? d, display: dd != null || br != null };
  }
}

export function hasMath(text) {
  return !formulas(String(text || "")).next().done;
}

/** Typeset every formula under `root` (or each of several roots); a no-op, and no download, when there's none. */
export async function typeset(...roots) {
  roots = roots.flat().filter(Boolean);
  if (!roots.some((r) => hasMath(r.textContent))) return;
  try { await load(); } catch { return; }   // offline, or blocked: the formulas stay as their source
  for (const root of roots) {
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
      acceptNode: (n) => (n.parentElement?.closest(SKIP) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
    });
    const nodes = [];
    while (walker.nextNode()) nodes.push(walker.currentNode);
    for (const n of nodes) {
      const t = n.nodeValue;
      if (!t.includes("$") && !t.includes("\\")) continue;
      let last = 0, frag = null;
      for (const f of formulas(t)) {
        frag ??= document.createDocumentFragment();
        frag.append(t.slice(last, f.at));
        const span = document.createElement("span");
        span.className = f.display ? "math display" : "math";
        try {
          span.innerHTML = window.katex.renderToString(f.tex, { displayMode: f.display, throwOnError: false, trust: false, maxSize: 20, maxExpand: 300, output: "html" });
        } catch {
          span.textContent = f.whole;
        }
        frag.append(span);
        last = f.at + f.whole.length;
      }
      if (frag) { frag.append(t.slice(last)); n.replaceWith(frag); }
    }
  }
}