diff --git a/docs/tex.md b/docs/tex.md index d5bde354f..f885ab7e7 100644 --- a/docs/tex.md +++ b/docs/tex.md @@ -48,3 +48,13 @@ c = \pm\sqrt{a^2 + b^2} \f\hat\xi\,e^{2 \pi i \xi x} \,d\xi ``` + +When possible, a ${tex`\TeX`} expression is compiled server-side, for faster rendering; however, when it uses variables, it needs to load the katex library, and to be compiled client-side: + +```js +const a = view(Inputs.range([0, 5], {step: 1})); +``` + +```tex show +\int_0^1 x^${a}dx = \frac1${a + 1} +``` diff --git a/package.json b/package.json index 6c2d3b915..7ccd07f9c 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "fast-deep-equal": "^3.1.3", "gray-matter": "^4.0.3", "highlight.js": "^11.8.0", + "katex": "^0.16.9", "linkedom": "^0.15.6", "markdown-it": "^13.0.2", "markdown-it-anchor": "^8.6.7", diff --git a/public/client.js b/public/client.js index cf7eeffab..fa708955b 100644 --- a/public/client.js +++ b/public/client.js @@ -161,6 +161,7 @@ async function mermaid() { export function define(cell) { const {id, inline, inputs = [], outputs = [], files = [], databases = [], body} = cell; + if (body == null) return; const variables = []; cellsById.get(id)?.variables.forEach((v) => v.delete()); cellsById.set(id, {cell, variables}); diff --git a/src/markdown.ts b/src/markdown.ts index 60067d1c8..b0382afed 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -4,6 +4,7 @@ import {type Patch, type PatchItem, getPatch} from "fast-array-diff"; import equal from "fast-deep-equal"; import matter from "gray-matter"; import hljs from "highlight.js"; +import katex from "katex"; import {parseHTML} from "linkedom"; import MarkdownIt from "markdown-it"; import {type RuleCore} from "markdown-it/lib/parser_core.js"; @@ -79,18 +80,36 @@ function uniqueCodeId(context: ParseContext, content: string): string { return id; } -function getLiveSource(content, language, option) { +function getLiveSource(content, language, option): {source?: string; html?: string} { return option === "no-run" - ? undefined + ? {} : language === "js" - ? content + ? {source: content} : language === "tex" - ? transpileTag(content, "tex.block", true) + ? maybeStaticTeX(content, {displayMode: true}) : language === "dot" - ? transpileTag(content, "dot", false) + ? {source: transpileTag(content, "dot", false)} : language === "mermaid" - ? transpileTag(content, "await mermaid", false) - : undefined; + ? {source: transpileTag(content, "await mermaid", false)} + : {}; +} + +function maybeStaticTeX(content, {displayMode = false} = {}) { + // We try SSR first. katex.renderToString errors when the expression contains + // some ${interpolation}, so this guarantees that interpolations will be + // handled in the browser. By way of consequence, TeX errors stemming from + // static text (e.g., ParseError on tex`\left{x}`) are handled in the browser, + // and don't stop the build process. + try { + // TODO: unique insertion of the TeX stylesheet? + return { + html: + katex.renderToString(content, {displayMode}) + + `` + }; + } catch { + return {source: transpileTag(content, displayMode ? "tex.block" : "tex", true)}; + } } function makeFenceRenderer(root: string, baseRenderer: RenderRule, sourcePath: string): RenderRule { @@ -99,7 +118,7 @@ function makeFenceRenderer(root: string, baseRenderer: RenderRule, sourcePath: s const [language, option] = token.info.split(" "); let result = ""; let count = 0; - const source = getLiveSource(token.content, language, option); + const {source, html} = getLiveSource(token.content, language, option); if (source != null) { const id = uniqueCodeId(context, token.content); const sourceLine = context.startLine + context.currentLine; @@ -115,6 +134,7 @@ function makeFenceRenderer(root: string, baseRenderer: RenderRule, sourcePath: s result += `
\n`; count++; } + if (html != null) result += html; if (source == null || option === "show") { result += baseRenderer(tokens, idx, options, context, self); count++; @@ -258,6 +278,13 @@ function makePlaceholderRenderer(root: string, sourcePath: string): RenderRule { return (tokens, idx, options, context: ParseContext) => { const id = uniqueCodeId(context, tokens[idx].content); const token = tokens[idx]; + + // inline TeX? + if (token.content.startsWith("tex`") && token.content.endsWith("`")) { + const {html} = maybeStaticTeX(token.content.slice(4, -1)); + if (html != null) return `${html}`; + } + const transpile = transpileJavaScript(token.content, { id, root, diff --git a/yarn.lock b/yarn.lock index 5d8ffb4d4..7cc7c7cfb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -654,6 +654,11 @@ commander@7: resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== +commander@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1794,6 +1799,13 @@ json5@^1.0.2: dependencies: minimist "^1.2.0" +katex@^0.16.9: + version "0.16.9" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.9.tgz#bc62d8f7abfea6e181250f85a56e4ef292dcb1fa" + integrity sha512-fsSYjWS0EEOwvy81j3vRA8TEAhQhKiqO+FQaKWp0m39qwOzHVBgAUBIXWj1pB+O2W3fIpNa6Y9KSKCVbfPhyAQ== + dependencies: + commander "^8.3.0" + keyv@^4.5.3: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"