/* Copyright 2026 Marimo. All rights reserved. */
import { type JSX, useLayoutEffect, useRef, useState } from "react";
import { z } from "zod";
import { once } from "@/utils/once";
import type {
IStatelessPlugin,
IStatelessPluginProps,
} from "../stateless-plugin";
/**
* TexPlugin
*
* A plugin that renders LaTeX, specialized for how our kernel processes
* LaTeX.
*/
export class TexPlugin implements IStatelessPlugin<{}> {
tagName = "marimo-tex";
validator = z.object({});
render(props: IStatelessPluginProps<{}>): JSX.Element {
return (
);
}
}
const importKatex = once(async () => {
return (await import("katex")).default;
});
const importMhChem = once(async () => {
// @ts-expect-error : type is not exported by katex
await import("katex/contrib/mhchem");
});
// Required, even if empty. (see https://github.com/KaTeX/KaTeX/issues/2513)
const macros = {
// KaTeX doesn't support \mbox; map it to the equivalent \text
"\\mbox": "\\text{#1}",
};
async function renderLatex(mount: HTMLElement, tex: string): Promise {
const [katex] = await Promise.all([importKatex(), importMhChem()]);
if (tex.startsWith("||(||(") && tex.endsWith("||)||)")) {
// when $$...$$ is used without newlines before/after the $$.
katex.render(tex.slice(6, -6), mount, {
displayMode: true,
globalGroup: true,
throwOnError: false,
macros: macros,
});
} else if (tex.startsWith("||(") && tex.endsWith("||)")) {
// Inline math, via $...$
katex.render(tex.slice(3, -3), mount, {
displayMode: false,
globalGroup: true,
throwOnError: false,
macros: macros,
});
} else if (tex.startsWith("||[") && tex.endsWith("||]")) {
// Display math, via $$...$$
katex.render(tex.slice(3, -3), mount, {
displayMode: true,
globalGroup: true,
throwOnError: false,
macros: macros,
});
}
}
const TexComponent = ({
host,
tex,
}: {
host: HTMLElement;
tex: string;
}): JSX.Element => {
const ref = useRef(null);
const [currentTex, setCurrentTex] = useState(tex);
// Watch for changes to the host element's direct children
useLayoutEffect(() => {
const observer = new MutationObserver(() => {
const newTex = host.textContent || host.innerHTML;
setCurrentTex(newTex);
});
observer.observe(host, {
childList: true,
characterData: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, [host]);
// The arithmatex markdown extension we use in Python produces nested
// marimo-tex tags when $$...$$ math is used in a paragraph, with dummy
// children that mess with rendering.
//
// eg., mo.md("hello $$x$$") produces
//
// ||(||(x||)||)
//
// while mo.md("$$x$$") produces the expected
//
// ||[x||]
//
// The nesting looks like a bug, or at least it makes rendering the latex
// more annoying. So we just get rid of the nesting here, since there
// isn't a simple way to do that in Python without bringing in a new
// dependency.
//
// When nested, the inner marimo-tex should not render because the outer
// marimo-tex's textContent includes the nested delimiters (||(||(x||)||))
// and will render correctly with displayMode: true. We detect this by
// checking if the parent element is also a marimo-tex.
const isNested = host.parentElement?.tagName.toLowerCase() === "marimo-tex";
// Re-render when the text content changes.
useLayoutEffect(() => {
if (ref.current && !isNested) {
renderLatex(ref.current, currentTex);
}
}, [currentTex, isNested]);
return ;
};