diff --git a/docs/webui.md b/docs/webui.md index 563a141cc..9c1571b0e 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -500,6 +500,38 @@ implementation accident: The `mermaid` dependency (11.12.1, MIT) is recorded in `release/dependency-licenses.json`. +## Math formulas in Markdown (KaTeX) + +Assistant messages and Markdown file previews also render math formulas, +in the same pipeline as Mermaid diagrams. Three input shapes are math; +every other use of the dollar sign stays prose: + +| Shape | Written as | Rendered as | Backed by | +| --- | --- | --- | --- | +| Inline | `$E=mc^2$` | KaTeX markup inside the paragraph | `webapp/lib/math-renderer.ts` (the marked `webuiMath` inline extension) | +| Display | `$$\frac{a}{b}$$` | A centred block (`.katex-display`) | the same tokenizer, `displayMode: true` | +| Fence | ```` ```math ```` | A centred block, dispatched through the language→renderer registry — the same seam `mermaid` uses, so neither fence language can shadow the other | `webapp/lib/math-renderer.ts` (`registerMathRenderer`) | + +| Aspect | Contract | Backed by | +| --- | --- | --- | +| False positives | A single `$` is math only when a closing `$` exists, the body stays on one line, and the body does not start with a digit: `costs $5 and $10`, `$HOME`, an unclosed `$\frac{` all stay prose | `webapp/lib/math-renderer.ts` (tokenizer guard) | +| Invalid formula | An input KaTeX cannot parse degrades to the **original source as code** — inline/display shapes become `raw`, a ```math fence falls back to the plain codeblock shell (the registry's existing throw path). The rest of the document is unaffected; the page never blanks | `webapp/lib/math-renderer.ts`, `webapp/lib/markdown.ts` (`safeLanguageRenderer`) | +| Sanitiser surface | KaTeX runs with `output: "html"` and emits only `span`, `svg`, `path`. The allowlist admits exactly those tags; `svg` keeps a fixed attribute set (`xmlns`, `width`, `height`, `viewBox`, `preserveAspectRatio`, `class`) with no `href`-like attribute, and ``/MathML stays a DROP tag — which is precisely why HTML-only output is configured. Inline `style` survives only on `span` and only when the value clears `isSafeStyleValue`: no parentheses rules out `url(...)`/`expression(...)`, and `position`/`background`/`behavior` are refused outright | `webapp/lib/markdown.ts` (`ALLOWED_TAGS`, `ALLOWED_ATTRS`, `isSafeStyleValue`) | +| React tree | The style attribute reaches React as a parsed object (`parseInlineStyle`), because React rejects a string `style` prop outright — passing it through would silently drop all KaTeX layout | `webapp/lib/markdown.ts` (`parseInlineStyle`), `components/markdown-html.tsx` | +| Trust | KaTeX `trust` stays `false`: `\href` renders as a red warning text node, never a link, so no URL can enter the DOM through a formula | `webapp/lib/math-renderer.ts` (`KATEX_OPTIONS`) | +| Theme | Formulas are inheriting text plus CSS transforms; they need no per-theme re-render (unlike Mermaid, which repaints on the theme flip) and pick up both themes' text colours from the design tokens | `webapp/styles/katex.css` | +| CSS + fonts | `webapp/styles/katex.css` is vendored from `katex/dist/katex.min.css` (the same version as the `katex` devDependency) with the `@font-face` sources repointed at the vendored fonts in `webapp/public/fonts/katex/` (60 font files + the MIT license notice). The stylesheet is loaded unconditionally from `app/layout.tsx` (~24 KB); fonts are served from `/fonts/katex/…` | `app/layout.tsx`, `webapp/styles/katex.css`, `webapp/public/fonts/katex/` | +| Bundle cost | `katex` JS is in the client bundle, not lazy-loaded the way Mermaid is: the math pipeline is synchronous string rendering (`renderToString`), and an inline `$…$` can appear mid-sentence. Accepted as a known cost; the lazy-load lever exists only if bundle budgets demand it | `webapp/lib/math-renderer.ts` | + +Upgrading `katex` regenerates both halves in the same commit: replace the +files in `webapp/public/fonts/katex/` from the new `dist/fonts/`, and +regenerate the stylesheet with +`sed 's|url(fonts/|url(/fonts/katex/|g' node_modules/katex/dist/katex.min.css +> webapp/styles/katex.css`. + +The `katex` dependency (0.18.7, MIT) is recorded in +`release/dependency-licenses.json`. + ## File preview toolbar and Markdown outline (slice 27) The preview component's header carries three controls, and the Markdown diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index a2c5dc137..44564d830 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -429,6 +429,51 @@ flowchart LR 依赖:`mermaid` 11.12.1(MIT),已登记于 `release/dependency-licenses.json`。 +## Markdown 里的数学公式(KaTeX) + +助手回复和 Markdown 文件预览现在能渲染数学公式,与 Mermaid 图走同一条 +渲染管线。三种写法会被当作公式,其余出现的美元符号一律按普通文本处理: + +| 写法 | 示例 | 效果 | +| --- | --- | --- | +| 行内公式 | `$E=mc^2$` | 段落内排版成公式,随正文流动 | +| 块级公式 | `$$\frac{a}{b}$$` | 居中独立成块显示 | +| 代码块 | 语言位置写 `math` 的代码围栏 | 同块级公式,与 `mermaid` 围栏走同一个分发注册表,互不影响 | + +**你会得到什么** + +- 上述三种写法都排版成真正的数学公式(分数、根号、求和号、矩阵等), + 渲染引擎是 KaTeX(`webapp/lib/math-renderer.ts`)。 +- 公式跟随界面浅色/深色主题,两种主题下对比度都正常——公式就是普通 + 继承文字色的内容,切换主题不需要重画。 +- 排版字体(KaTeX 字体)随应用自带,不依赖系统装没装数学字体。 + +**什么不会误伤** + +- `成本 $5 and $10`、`$HOME`、没写闭合 `$` 的片段——都按普通文本原样 + 显示。单个 `$` 只有在存在闭合 `$`、内容不跨行、且不以数字开头时才 + 被当作公式。 + +**公式写错了会怎样** + +- 该公式**降级为代码样式显示原始写法**(行内公式显示为行内代码,`math` + 代码块显示为普通代码块),原文一个字符都不丢,页面照常渲染,不会 + 白屏(`webapp/lib/markdown.ts` 的降级路径)。 +- 公式里无法夹带链接:`\href` 之类的可信功能默认关闭,只会显示成红色 + 警示文字,不会变成可点的 URL。 + +**限制** + +- 公式排版样式与字体文件随应用打包(样式表约 24 KB,按需加载字体重 + 量很小);JS 渲染库进入前端主包(gzip 约 90 KB),不像 Mermaid 那样 + 懒加载——行内公式可能出现在句子中间,同步渲染是正确性前提。这是 + 记录在案的成本,若前端体积预算吃紧再评估懒加载方案。 +- 升级 KaTeX 版本时需要同步更新两处:`webapp/public/fonts/katex/` 下的 + 字体文件与 `webapp/styles/katex.css`(由 `katex/dist/katex.min.css` 改写 + 字体路径生成,方法记录在英文文档同节)。 + +依赖:`katex` 0.18.7(MIT),已登记于 `release/dependency-licenses.json`。 + ## 文件预览工具栏与 Markdown 大纲(slice 27) 预览组件顶栏新增三个控件,Markdown 预览增加大纲面板。这也是 diff --git a/packages/webui/docs/CAPABILITIES.md b/packages/webui/docs/CAPABILITIES.md index fec826bbf..4c1c4b2db 100644 --- a/packages/webui/docs/CAPABILITIES.md +++ b/packages/webui/docs/CAPABILITIES.md @@ -62,6 +62,7 @@ single index that satisfies the check. | Markdown rendering (headings, lists, code) | ✅ | `webapp/lib/markdown.ts` wrapping the workspace `marked` package (`packages/tui` already depends on it; no CDN) | | Syntax highlighting in code blocks | ✅ | `marked` code renderer (`webapp/lib/markdown.ts`) + CSS classes from `webapp/styles/official-utilities.css` | | Mermaid diagrams in markdown (slice 23) | ✅ | a fenced code block whose language token is `mermaid` renders as a diagram instead of a code block. Fence languages dispatch through the language→renderer registry (`webapp/lib/markdown.ts#registerLanguageRenderer`, lines 107-113); mermaid self-registers on import (`webapp/lib/mermaid-renderer.ts:64-71`), and a renderer for any other fence language plugs into the same seam — the markdown main flow never branches on a language name. The chart library is `import()`-ed on the first diagram of a page (`webapp/components/mermaid-block.tsx:62-66`) and its chunk is served with a one-year immutable cache (`server/lib/static.js:66`); a page without a mermaid fence never requests it. Diagrams follow the light/dark theme (`components/markdown-html.tsx:52-66` re-renders on the `` class flip; `components/mermaid-block.tsx:156-166,223-229` re-initialises mermaid per theme). A syntax error renders a legible failure card — error text plus the original source in a copyable `
` (`components/mermaid-block.tsx:285-307`) — while the rest of the document renders normally. Limits: diagrams scale to the column width and scroll when wider (`webapp/styles/mermaid.css:62-80`); CJK labels render via the font stack at `components/mermaid-block.tsx:109`; a diagram is never a heading, so it appears in no heading-derived outline (the fence emits a `
`/`
` pair, not `h1`-`h6` — `lib/mermaid-renderer.ts:44-57`). Dependency `mermaid` 11.12.1 (MIT) is recorded in `release/dependency-licenses.json`. | +| Math formulas via KaTeX (inline `$…$`, display `$$…$$`, ```` ```math ```` fences) | ✅ | the same pipeline as Mermaid: an inline marked extension (`webuiMath`) tokenises `$…$`/`$$…$$` and the ```math fence dispatches through the language→renderer registry (`webapp/lib/math-renderer.ts`, self-registered on import), so neither fence language can shadow the other. A single `$` is math only with a closing delimiter on one line and a non-digit start — `costs $5 and $10`, `$HOME`, unclosed `$` stay prose. A formula KaTeX cannot parse degrades to the original source as code (inline ``, fence → the plain codeblock shell) — the page never blanks. KaTeX runs with `output: "html"` (emits only `span`/`svg`/`path`, which the sanitiser allowlist admits with a fixed attribute set; ``/MathML stays a DROP tag) and `trust: false` (`\href` renders as red warning text, never a link). Inline `style` survives only on `span` and only when the value clears `isSafeStyleValue` — no parentheses (no `url()`/`expression()`), and `position`/`background`/`behavior` are refused; `components/markdown-html.tsx` converts the style attribute to a React style object (`parseInlineStyle`), since React rejects a string style prop. CSS is vendored at `webapp/styles/katex.css` (from `katex/dist/katex.min.css`, `@font-face` repointed to `/fonts/katex/…`) and loaded from `app/layout.tsx`; fonts (60 files + MIT notice) are vendored at `webapp/public/fonts/katex/`. Formulas are inheriting text — both themes work without a re-render. Known cost: `katex` JS ships in the client bundle rather than lazy-loading like mermaid (the math pipeline is synchronous `renderToString`). Dependency `katex` 0.18.7 (MIT) is recorded in `release/dependency-licenses.json`. Tests: `webapp/test/markdown-math.test.ts`. | | Preview toolbar: refresh / edit / save (slice 27) | ✅ | the preview header carries ↻ refresh (re-read from disk, scroll position restored; a deleted/renamed file keeps the last content and shows an explicit banner — `components/file-preview.tsx` refresh handler), a 预览/编辑 toggle (text previews only, `lib/preview-edit.ts#canEditPreview`; credential-shaped paths must pass an explicit confirmation card first — `editRequiresCredentialConfirm` on the slice-16 predicate), and ✓ save (never automatic; success shows "saved at HH:MM", failure keeps the buffer and states the server's reason). Saves go through `POST /api/fs/write` (`api.ts#saveFsFile`) carrying the `(expectedMtime, expectedSize)` baseline from the load; a drifted baseline answers a conflict card (overwrite / reload) rather than a silent overwrite. The write answers with the **realpath-normalised absolute path** (the shared gate resolves symlinks before anything else — the same slice-16 form every `/api/fs/*` route returns; on macOS a `/var/...` fixture therefore answers `/private/var/...`). | | Markdown outline panel (slice 27) | ✅ | `webapp/components/markdown-toc.tsx` + `webapp/lib/markdown-toc.ts`. The outline is extracted from the RENDERED DOM (`querySelectorAll("h1,…,h6")`), never a second markdown parse; heading ids are assigned onto those nodes (stable slugs, `-2`/`-3` dedupe suffixes — `headingSlug`/`extractOutline`, pinned by `webapp/test/markdown-toc.test.ts`). A click `preventDefault`s the anchor and smooth-scrolls the heading into view via `scrollIntoView`; the panel is sticky within the scroll viewport (max-height pinned to the viewport's height) and the active entry follows the scroll position. Documents without headings render no panel; a heading nested inside a `.mermaid-block` is excluded defensively (`isOutlineHeading`); entries are readable in both themes via design tokens. Below ~300px of content width the outline hides (observing the stable `.file-preview-body` width, not the markdown host — watching the host creates a show/hide feedback loop) rather than squeezing the document. | | Cancel mid-run | ✅ | acp `session/cancel` is sent as a notification, pinned on the cid's active child (`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`). The hard-kill fallback (`/api/stop` → SIGTERM/SIGKILL) is only used when the notification cannot be delivered. The acp session may emit a few extra events before draining. | diff --git a/packages/webui/docs/CAPABILITIES.zh-CN.md b/packages/webui/docs/CAPABILITIES.zh-CN.md index fadb99c4a..845861a30 100644 --- a/packages/webui/docs/CAPABILITIES.zh-CN.md +++ b/packages/webui/docs/CAPABILITIES.zh-CN.md @@ -53,6 +53,7 @@ CI 会对上述每一个名称是否出现在本文档中进行断言 | Markdown 渲染(标题、列表、代码) | ✅ | `lib/marked.min.js` 本地内置(不走 CDN) | | 代码块语法高亮 | ✅ | highlight.js(本地副本) | | Markdown 内 Mermaid 图渲染(slice 23) | ✅ | 代码围栏语言写 `mermaid` 即渲染为图,而非代码块。围栏语言经"语言→渲染器"注册表分发(`webapp/lib/markdown.ts` 的 `registerLanguageRenderer`,107-113 行);mermaid 在模块导入时自注册(`webapp/lib/mermaid-renderer.ts:64-71`),其他语言的渲染器可经同一接缝接入——markdown 主流程不针对语言名写分支。图表库在页面首张图出现时才动态加载(`components/mermaid-block.tsx:62-66`),该文件带一年 immutable 强缓存(`server/lib/static.js:66`);没有 mermaid 围栏的页面完全不加载。图跟随浅色/深色主题(`components/markdown-html.tsx:52-66` 监听 `` class;`components/mermaid-block.tsx:156-166、223-229` 按主题重新初始化)。语法错误时渲染可读的失败卡片——错误信息 + 可复制的原始源码(`components/mermaid-block.tsx:285-307`)——文档其余部分照常渲染。限制:图按列宽缩放、超宽时在卡片内横向滚动(`webapp/styles/mermaid.css:62-80`);中文标签经字体栈正常显示(`components/mermaid-block.tsx:109`);图不产生标题,因此不会进入任何按标题组织的大纲(围栏只产出 `
`/`
` 占位对,不产出 `h1`-`h6`,见 `lib/mermaid-renderer.ts:44-57`)。依赖 `mermaid` 11.12.1(MIT)已登记于 `release/dependency-licenses.json`。 | +| Markdown 数学公式(KaTeX,行内 `$…$`、块级 `$$…$$`、```` ```math ```` 代码块) | ✅ | 与 Mermaid 同一条管线:行内经 marked 扩展 `webuiMath` 识别 `$…$`/`$$…$$`,`math` 代码块经语言→渲染器注册表分发(`webapp/lib/math-renderer.ts`,导入时自注册),两类围栏互不干扰。单个 `$` 仅在存在同 行闭合定界符、内容不以数字开头时才视为公式——`成本 $5 and $10`、`$HOME`、未闭合的 `$` 均按普通文本显示。公式无法解析时降级为原始写法的代码样式(行内降级 ``,代码块降级为普通代码块),页面绝不白屏。KaTeX 以 `output: "html"` 运行(只产出 `span`/`svg`/`path`,清洗白名单按固定属性集放行;``/MathML 仍为整体丢弃标签),`trust: false`(`\href` 只显示红色警示文字,不会成为链接)。内联 `style` 仅在 `span` 上保留且值须通过 `isSafeStyleValue` 校验——禁止括号(杜绝 `url()`/`expression()`),`position`/`background`/`behavior` 直接拒绝;`components/markdown-html.tsx` 将 style 属性解析为 React 样式对象(`parseInlineStyle`,React 不接受字符串 style)。样式表 vendored 于 `webapp/styles/katex.css`(源自 `katex/dist/katex.min.css`,`@font-face` 指向 `/fonts/katex/…`),由 `app/layout.tsx` 加载;字体(60 个文件 + MIT 许可声明)vendored 于 `webapp/public/fonts/katex/`。公式为继承文字色的内容,深浅主题均正常、无需重渲染。已知成本:`katex` JS 随前端主包加载,不像 mermaid 懒加载(数学管线是同步 `renderToString`)。依赖 `katex` 0.18.7(MIT)已登记于 `release/dependency-licenses.json`。测试:`webapp/test/markdown-math.test.ts`。 | | 运行中取消 | ✅ | acp `session/cancel` 以 notification 形式发送,并钉在该 cid 的活动子进程上(`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`)。只有当 notification 无法投递时,才会走硬杀兜底(`/api/stop` → SIGTERM/SIGKILL)。acp 会话在排空前可能还会再发出几个事件。 | | 回退 / 分叉某条消息 | ⚠ | 引擎已实现 `session/fork` 和 `session/resume`(`MCODE_ACP_CAPABILITIES.fork / .resume = true`),但目前 webui 还没有路由暴露它们——参见 [§13](CAPABILITIES.zh-CN.md#13-要启用--行-mcode-需要增加什么)。 | | 编辑已发送的消息并重新发送 | ❌ | acp 协议未暴露 | diff --git a/packages/webui/package.json b/packages/webui/package.json index 44fbbdf91..f21d60f8c 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -34,6 +34,7 @@ "antd": "5.29.3", "autoprefixer": "10.6.1", "highlight.js": "10.7.3", + "katex": "0.18.7", "marked": "18.0.12", "mermaid": "11.12.1", "next": "14.2.35", @@ -90,7 +91,7 @@ }, { "name": "lan-sharing", - "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in \u2014 the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." + "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in — the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." }, { "name": "token-auth", diff --git a/packages/webui/webapp/app/layout.tsx b/packages/webui/webapp/app/layout.tsx index dfeab1b2a..b7f13fb56 100644 --- a/packages/webui/webapp/app/layout.tsx +++ b/packages/webui/webapp/app/layout.tsx @@ -11,6 +11,12 @@ import "../styles/mavis-dropdown.css"; import "../styles/desktop-typography.css"; import "../styles/code-preview.css"; import "../styles/mermaid.css"; +// KaTeX stylesheet, vendored from `katex/dist/katex.min.css` (same version as +// the `katex` devDependency) with the @font-face sources repointed at the +// vendored fonts in `public/fonts/katex/`. Loaded unconditionally: it is +// ~24 KB and the formulas' geometry classes must exist before any message +// with math renders. +import "../styles/katex.css"; export const metadata: Metadata = { title: "MiniMax Code", diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx index 27863a037..13f32b105 100644 --- a/packages/webui/webapp/components/chat.tsx +++ b/packages/webui/webapp/components/chat.tsx @@ -6,6 +6,7 @@ import * as api from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import { MarkdownHtml } from "./markdown-html"; import "../lib/mermaid-renderer"; // registers the mermaid language renderer +import "../lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { reportActionError } from "@/lib/action-errors"; import { findSubagentForBlock } from "@/lib/agent-team-lookup"; import { badgeLabelAndGlyph, agentLabel } from "@/lib/i18n-agent-team"; diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx index 84b94a370..534eb3150 100644 --- a/packages/webui/webapp/components/file-preview.tsx +++ b/packages/webui/webapp/components/file-preview.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import "@/lib/mermaid-renderer"; // registers the mermaid language renderer +import "@/lib/math-renderer"; // registers KaTeX (inline $…$, $$…$$, ```math fences) import { MarkdownHtml } from "@/components/markdown-html"; import { CodeView as IdeCodeView } from "@/components/code-view"; import { diff --git a/packages/webui/webapp/components/markdown-html.tsx b/packages/webui/webapp/components/markdown-html.tsx index cbc41f5c8..e7f7608b5 100644 --- a/packages/webui/webapp/components/markdown-html.tsx +++ b/packages/webui/webapp/components/markdown-html.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { MermaidBlock } from "./mermaid-block"; +import { parseInlineStyle } from "../lib/markdown"; /** * Render pre-sanitised HTML and mount mermaid blocks as real React @@ -157,6 +158,12 @@ function htmlToReact(html: string, theme: "light" | "dark"): ReactNode { const name = attr.name.toLowerCase(); if (name === "class") { props.className = attr.value; + } else if (name === "style") { + // KaTeX layout lives in inline styles, and React requires the + // style prop as an object — a string is rejected with a console + // error and the styles never apply. The value was already vetted + // by the sanitiser's `isSafeStyleValue`; this only reshapes it. + props.style = parseInlineStyle(attr.value); } else { props[attr.name] = attr.value; } diff --git a/packages/webui/webapp/lib/markdown.ts b/packages/webui/webapp/lib/markdown.ts index 69778f1e2..1889907f5 100644 --- a/packages/webui/webapp/lib/markdown.ts +++ b/packages/webui/webapp/lib/markdown.ts @@ -211,6 +211,12 @@ export const ALLOWED_TAGS = new Set([ "p", "br", "hr", "strong", "em", "del", "code", "pre", "blockquote", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "a", "table", "thead", "tbody", "tr", "th", "td", + // KaTeX HTML output is built exclusively from `span`, `svg` and `path` + // (`lib/math-renderer.ts`). `svg` is therefore removed from the DROP set + // below, and its subtree is walked like any other element: a `${html}`, "text/html"); - const DROP = new Set(["script", "style", "iframe", "object", "embed", "link", "meta", "form", "input", "svg", "math"]); + // `svg` is NOT in this set: KaTeX (lib/math-renderer.ts) emits real + // `` geometry and needs it to survive. Its subtree is still walked — + // scripts, event handlers and any attribute outside ALLOWED_ATTRS are + // stripped exactly as they are everywhere else. + const DROP = new Set(["script", "style", "iframe", "object", "embed", "link", "meta", "form", "input", "math"]); const walk = (node: Element): void => { for (const child of [...node.children]) { @@ -272,6 +357,13 @@ function sanitize(html: string): string { } if (name === "href" && !SAFE_URL.test(attr.value.trim())) { child.removeAttribute(attr.name); + continue; + } + // `style` passes the name check only where the allowlist admits it; + // the value must additionally clear the safe-grammar check, so a + // hand-written `style="background:url(...)"` loses the attribute. + if (name === "style" && !isSafeStyleValue(attr.value)) { + child.removeAttribute(attr.name); } } diff --git a/packages/webui/webapp/lib/math-renderer.ts b/packages/webui/webapp/lib/math-renderer.ts new file mode 100644 index 000000000..2e8e44638 --- /dev/null +++ b/packages/webui/webapp/lib/math-renderer.ts @@ -0,0 +1,150 @@ +// Math renderer (KaTeX) for assistant messages and markdown previews. +// +// Three input shapes render as formulas, mirroring the desktop client: +// +// - `$E=mc^2$` inline math inside prose; +// - `$$…$$` display math (block-level, centred); +// - ```math a fenced code block whose language token is `math`. +// +// The first two are a marked inline extension (`webuiMath`); the third goes +// through the language→renderer registry (`registerLanguageRenderer`), the +// same seam `mermaid` uses — the markdown main flow stays language-agnostic +// and the two features cannot shadow each other. +// +// KaTeX is used in HTML output mode (`output: "html"`). That mode emits only +// `span`, `svg` and `path` elements, which is exactly the surface the +// sanitiser allowlist in `lib/markdown.ts` is extended for. The default +// "htmlAndMathml" mode would additionally emit a `` MathML tree, which +// the sanitiser drops whole (it is a DROP tag) — silently producing half a +// formula — so the MathML leg is switched off rather than rendered broken. +// +// Failure contract (acceptance criterion: a broken formula degrades to the +// original text, never a blank page): +// +// - inline/display `$…$`: `katex.renderToString` throws on invalid input +// (`throwOnError` defaults to true and is set explicitly); the catch +// emits the raw source (delimiters included) as +// `` — the same markup ordinary inline code +// uses, so it inherits the existing code styling in both themes. +// - ```math fence: the renderer lets the error propagate and the registry's +// `safeLanguageRenderer` wrapper falls back to the plain fenced-block +// shell, so the original source is visible as a code block — identical +// to how a throwing third-party renderer behaves. +// +// `\href` and other trust-gated features stay disabled (KaTeX's `trust` +// defaults to false and is not raised here), so a formula cannot smuggle a +// link URL through the math parser. + +import katex from "katex"; +import { marked } from "marked"; + +import { escapeHtml, registerLanguageRenderer } from "./markdown"; + +/** KaTeX options shared by all three input shapes. */ +const KATEX_OPTIONS = { + // HTML-only output: the only element surface we extend the sanitiser for. + output: "html", + // An invalid formula must throw so the caller can degrade to ``; + // `throwOnError: true` is KaTeX's default but is pinned here because the + // degradation contract depends on it. + throwOnError: true, + // Non-fatal strict-mode complaints (e.g. unicode text in math mode) are + // rendered as-is; they must not spam the console for model output. + strict: false, +} as const; + +/** Render one formula; throws on invalid input (see module comment). */ +function renderMathToHtml(source: string, displayMode: boolean): string { + return katex.renderToString(source, { ...KATEX_OPTIONS, displayMode }); +} + +/** + * Build the HTML for a `$…$` / `$$…$$` token. + * + * The inline extension tokenises (below) and renders (here); on a KaTeX + * parse error the raw source — delimiters included — degrades to inline + * code, which is the documented failure shape for prose-embedded math. + */ +function mathExtensionRenderer(token: { + text?: string; + raw?: string; + display?: boolean; +}): string { + const source = token.text ?? ""; + try { + return renderMathToHtml(source, token.display === true); + } catch { + return `${escapeHtml(token.raw ?? source)}`; + } +} + +/** + * Inline math extension. + * + * Tokenising rules are intentionally conservative so ordinary prose never + * turns into formulas: + * + * - a single-dollar body cannot span lines and cannot start with a digit, + * so `costs $5 and $10` stays prose; + * - an unclosed `$` or `$$` never matches (the body needs a closing + * delimiter) and the text passes through untouched; + * - `\(`…`\)` is deliberately NOT math here: the start hook reserves the + * index only so a later `$` is not consumed first; the tokenizer still + * requires a dollar delimiter, matching the desktop behaviour. + */ +const MATH_START = /\$\$?|\\\(/u; + +marked.use({ + extensions: [ + { + name: "webuiMath", + level: "inline", + start(source: string): number | undefined { + const index = source.search(MATH_START); + return index >= 0 ? index : undefined; + }, + tokenizer(source: string) { + const match = source.match(/^(\$\$?)([\s\S]+?)\1/u); + if (!match) return undefined; + const delimiter = match[1]; + const body = match[2]; + if (delimiter === undefined || body === undefined) return undefined; + // Single-dollar inline math cannot cross a line or attach to a + // number — the `$5 and $10` false-positive guard. + if (delimiter === "$" && (body.includes("\n") || /^\d/u.test(body))) { + return undefined; + } + return { + type: "webuiMath", + raw: match[0], + text: body, + display: delimiter === "$$", + }; + }, + renderer(token) { + return mathExtensionRenderer( + token as unknown as { text?: string; raw?: string; display?: boolean }, + ); + }, + }, + ], +}); + +/** + * Install the ```math fence renderer. + * + * Success emits KaTeX display-mode HTML directly (a `.katex-display` span, + * block-level through the KaTeX stylesheet). Failure throws into the + * registry's `safeLanguageRenderer` wrapper, which falls back to the plain + * codeblock shell — the source stays visible as a code block. + * + * Idempotent (`Map.set`), matching the mermaid renderer's install contract. + */ +export function registerMathRenderer(): void { + registerLanguageRenderer("math", (source) => renderMathToHtml(source, true)); +} + +// Auto-register on module import, the same pattern as +// `lib/mermaid-renderer.ts`: importing this module from a client entry is +// the whole wiring; the markdown main flow stays language-agnostic. +registerMathRenderer(); diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_AMS-Regular.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_AMS-Regular.ttf new file mode 100644 index 000000000..c6f9a5e7c Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_AMS-Regular.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_AMS-Regular.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_AMS-Regular.woff new file mode 100644 index 000000000..b804d7b33 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_AMS-Regular.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_AMS-Regular.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_AMS-Regular.woff2 new file mode 100644 index 000000000..0acaaff03 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_AMS-Regular.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Bold.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Bold.ttf new file mode 100644 index 000000000..9ff4a5e04 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Bold.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Bold.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Bold.woff new file mode 100644 index 000000000..9759710d1 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Bold.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Bold.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Bold.woff2 new file mode 100644 index 000000000..f390922ec Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Bold.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Regular.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Regular.ttf new file mode 100644 index 000000000..f522294ff Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Regular.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Regular.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Regular.woff new file mode 100644 index 000000000..9bdd534fd Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Regular.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Regular.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Regular.woff2 new file mode 100644 index 000000000..75344a1f9 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Caligraphic-Regular.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Bold.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Bold.ttf new file mode 100644 index 000000000..4e98259c3 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Bold.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Bold.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Bold.woff new file mode 100644 index 000000000..e7730f662 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Bold.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Bold.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Bold.woff2 new file mode 100644 index 000000000..395f28bea Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Bold.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Regular.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Regular.ttf new file mode 100644 index 000000000..b8461b275 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Regular.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Regular.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Regular.woff new file mode 100644 index 000000000..acab069f9 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Regular.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Regular.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Regular.woff2 new file mode 100644 index 000000000..735f6948d Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Fraktur-Regular.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Bold.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Bold.ttf new file mode 100644 index 000000000..4060e627d Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Bold.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Bold.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Bold.woff new file mode 100644 index 000000000..f38136ac1 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Bold.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Bold.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Bold.woff2 new file mode 100644 index 000000000..ab2ad21da Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Bold.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-BoldItalic.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-BoldItalic.ttf new file mode 100644 index 000000000..dc007977e Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-BoldItalic.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-BoldItalic.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-BoldItalic.woff new file mode 100644 index 000000000..67807b0bd Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-BoldItalic.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-BoldItalic.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-BoldItalic.woff2 new file mode 100644 index 000000000..5931794de Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-BoldItalic.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Italic.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Italic.ttf new file mode 100644 index 000000000..0e9b0f354 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Italic.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Italic.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Italic.woff new file mode 100644 index 000000000..6f43b594b Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Italic.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Italic.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Italic.woff2 new file mode 100644 index 000000000..b50920e13 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Italic.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Regular.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Regular.ttf new file mode 100644 index 000000000..dd45e1ed2 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Regular.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Regular.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Regular.woff new file mode 100644 index 000000000..21f581296 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Regular.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Regular.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Regular.woff2 new file mode 100644 index 000000000..eb24a7ba2 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Main-Regular.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Math-BoldItalic.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-BoldItalic.ttf new file mode 100644 index 000000000..728ce7a1e Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-BoldItalic.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Math-BoldItalic.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-BoldItalic.woff new file mode 100644 index 000000000..0ae390d74 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-BoldItalic.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Math-BoldItalic.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-BoldItalic.woff2 new file mode 100644 index 000000000..29657023a Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-BoldItalic.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Math-Italic.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-Italic.ttf new file mode 100644 index 000000000..70d559b4e Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-Italic.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Math-Italic.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-Italic.woff new file mode 100644 index 000000000..eb5159d4c Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-Italic.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Math-Italic.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-Italic.woff2 new file mode 100644 index 000000000..215c143fd Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Math-Italic.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Bold.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Bold.ttf new file mode 100644 index 000000000..2f65a8a3a Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Bold.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Bold.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Bold.woff new file mode 100644 index 000000000..8d47c02d9 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Bold.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Bold.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Bold.woff2 new file mode 100644 index 000000000..cfaa3bda5 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Bold.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Italic.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Italic.ttf new file mode 100644 index 000000000..d5850df98 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Italic.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Italic.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Italic.woff new file mode 100644 index 000000000..7e02df963 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Italic.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Italic.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Italic.woff2 new file mode 100644 index 000000000..349c06dc6 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Italic.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Regular.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Regular.ttf new file mode 100644 index 000000000..537279f6b Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Regular.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Regular.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Regular.woff new file mode 100644 index 000000000..31b84829b Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Regular.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Regular.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Regular.woff2 new file mode 100644 index 000000000..a90eea85f Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_SansSerif-Regular.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Script-Regular.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Script-Regular.ttf new file mode 100644 index 000000000..fd679bf37 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Script-Regular.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Script-Regular.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Script-Regular.woff new file mode 100644 index 000000000..0e7da821e Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Script-Regular.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Script-Regular.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Script-Regular.woff2 new file mode 100644 index 000000000..b3048fc11 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Script-Regular.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size1-Regular.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Size1-Regular.ttf new file mode 100644 index 000000000..871fd7d19 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size1-Regular.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size1-Regular.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Size1-Regular.woff new file mode 100644 index 000000000..7f292d911 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size1-Regular.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size1-Regular.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Size1-Regular.woff2 new file mode 100644 index 000000000..c5a8462fb Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size1-Regular.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size2-Regular.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Size2-Regular.ttf new file mode 100644 index 000000000..7a212caf9 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size2-Regular.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size2-Regular.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Size2-Regular.woff new file mode 100644 index 000000000..d241d9be2 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size2-Regular.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size2-Regular.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Size2-Regular.woff2 new file mode 100644 index 000000000..e1bccfe24 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size2-Regular.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size3-Regular.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Size3-Regular.ttf new file mode 100644 index 000000000..00bff3495 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size3-Regular.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size3-Regular.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Size3-Regular.woff new file mode 100644 index 000000000..e6e9b658d Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size3-Regular.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size3-Regular.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Size3-Regular.woff2 new file mode 100644 index 000000000..249a28662 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size3-Regular.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size4-Regular.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Size4-Regular.ttf new file mode 100644 index 000000000..74f08921f Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size4-Regular.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size4-Regular.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Size4-Regular.woff new file mode 100644 index 000000000..e1ec54576 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size4-Regular.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Size4-Regular.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Size4-Regular.woff2 new file mode 100644 index 000000000..680c13085 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Size4-Regular.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Typewriter-Regular.ttf b/packages/webui/webapp/public/fonts/katex/KaTeX_Typewriter-Regular.ttf new file mode 100644 index 000000000..c83252c57 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Typewriter-Regular.ttf differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Typewriter-Regular.woff b/packages/webui/webapp/public/fonts/katex/KaTeX_Typewriter-Regular.woff new file mode 100644 index 000000000..2432419f2 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Typewriter-Regular.woff differ diff --git a/packages/webui/webapp/public/fonts/katex/KaTeX_Typewriter-Regular.woff2 b/packages/webui/webapp/public/fonts/katex/KaTeX_Typewriter-Regular.woff2 new file mode 100644 index 000000000..771f1af70 Binary files /dev/null and b/packages/webui/webapp/public/fonts/katex/KaTeX_Typewriter-Regular.woff2 differ diff --git a/packages/webui/webapp/public/fonts/katex/LICENSE.katex-fonts b/packages/webui/webapp/public/fonts/katex/LICENSE.katex-fonts new file mode 100644 index 000000000..8370afc11 --- /dev/null +++ b/packages/webui/webapp/public/fonts/katex/LICENSE.katex-fonts @@ -0,0 +1,4 @@ +The KaTeX font files in this directory are distributed under the MIT License. +They correspond to the `katex` npm package used by this WebUI. See the +upstream license at https://github.com/KaTeX/KaTeX/blob/main/LICENSE. + diff --git a/packages/webui/webapp/styles/katex.css b/packages/webui/webapp/styles/katex.css new file mode 100644 index 000000000..a32d8acbd --- /dev/null +++ b/packages/webui/webapp/styles/katex.css @@ -0,0 +1 @@ +@font-face{font-display:block;font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(/fonts/katex/KaTeX_AMS-Regular.woff2) format("woff2"),url(/fonts/katex/KaTeX_AMS-Regular.woff) format("woff"),url(/fonts/katex/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(/fonts/katex/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(/fonts/katex/KaTeX_Caligraphic-Bold.woff) format("woff"),url(/fonts/katex/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(/fonts/katex/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(/fonts/katex/KaTeX_Caligraphic-Regular.woff) format("woff"),url(/fonts/katex/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(/fonts/katex/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(/fonts/katex/KaTeX_Fraktur-Bold.woff) format("woff"),url(/fonts/katex/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(/fonts/katex/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(/fonts/katex/KaTeX_Fraktur-Regular.woff) format("woff"),url(/fonts/katex/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(/fonts/katex/KaTeX_Main-Bold.woff2) format("woff2"),url(/fonts/katex/KaTeX_Main-Bold.woff) format("woff"),url(/fonts/katex/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(/fonts/katex/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(/fonts/katex/KaTeX_Main-BoldItalic.woff) format("woff"),url(/fonts/katex/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(/fonts/katex/KaTeX_Main-Italic.woff2) format("woff2"),url(/fonts/katex/KaTeX_Main-Italic.woff) format("woff"),url(/fonts/katex/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(/fonts/katex/KaTeX_Main-Regular.woff2) format("woff2"),url(/fonts/katex/KaTeX_Main-Regular.woff) format("woff"),url(/fonts/katex/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(/fonts/katex/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(/fonts/katex/KaTeX_Math-BoldItalic.woff) format("woff"),url(/fonts/katex/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(/fonts/katex/KaTeX_Math-Italic.woff2) format("woff2"),url(/fonts/katex/KaTeX_Math-Italic.woff) format("woff"),url(/fonts/katex/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(/fonts/katex/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(/fonts/katex/KaTeX_SansSerif-Bold.woff) format("woff"),url(/fonts/katex/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(/fonts/katex/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(/fonts/katex/KaTeX_SansSerif-Italic.woff) format("woff"),url(/fonts/katex/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(/fonts/katex/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(/fonts/katex/KaTeX_SansSerif-Regular.woff) format("woff"),url(/fonts/katex/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(/fonts/katex/KaTeX_Script-Regular.woff2) format("woff2"),url(/fonts/katex/KaTeX_Script-Regular.woff) format("woff"),url(/fonts/katex/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(/fonts/katex/KaTeX_Size1-Regular.woff2) format("woff2"),url(/fonts/katex/KaTeX_Size1-Regular.woff) format("woff"),url(/fonts/katex/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(/fonts/katex/KaTeX_Size2-Regular.woff2) format("woff2"),url(/fonts/katex/KaTeX_Size2-Regular.woff) format("woff"),url(/fonts/katex/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(/fonts/katex/KaTeX_Size3-Regular.woff2) format("woff2"),url(/fonts/katex/KaTeX_Size3-Regular.woff) format("woff"),url(/fonts/katex/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(/fonts/katex/KaTeX_Size4-Regular.woff2) format("woff2"),url(/fonts/katex/KaTeX_Size4-Regular.woff) format("woff"),url(/fonts/katex/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(/fonts/katex/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(/fonts/katex/KaTeX_Typewriter-Regular.woff) format("woff"),url(/fonts/katex/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;position:relative;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.47"}.katex .katex-mathml{border:0;-webkit-clip-path:inset(50%);clip-path:inset(50%);height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.katex .katex-html>.newline{display:block}.katex .base{position:relative;white-space:nowrap;width:-webkit-min-content;width:-moz-min-content;width:min-content}.katex .base,.katex .strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathnormal{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-style:italic;font-weight:700}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathboldfrak,.katex .textboldfrak{font-family:KaTeX_Fraktur;font-weight:700}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .mathsfit,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{border-collapse:collapse;display:inline-table;table-layout:fixed}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;position:relative;vertical-align:bottom}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vlist>span>.pstrut{overflow:hidden;width:0}.katex .vlist-t2{margin-right:-2px}.katex .vlist-s{display:table-cell;font-size:1px;min-width:2px;vertical-align:bottom;width:2px}.katex .vbox{align-items:baseline;display:inline-flex;flex-direction:column}.katex .hbox{width:100%}.katex .hbox,.katex .thinbox{display:inline-flex;flex-direction:row}.katex .thinbox{max-width:0;width:0}.katex .msupsub{text-align:left}.katex .mfrac>span>span{text-align:center}.katex .mfrac .frac-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline,.katex .hline,.katex .mfrac .frac-line,.katex .overline .overline-line,.katex .rule,.katex .underline .underline-line{min-height:1px}.katex .mspace{display:inline-block}.katex .smash{display:inline;line-height:0}.katex .clap,.katex .llap,.katex .rlap{position:relative;width:0}.katex .clap>.inner,.katex .llap>.inner,.katex .rlap>.inner{position:absolute}.katex .clap>.fix,.katex .llap>.fix,.katex .rlap>.fix{display:inline-block}.katex .llap>.inner{right:0}.katex .clap>.inner,.katex .rlap>.inner{left:0}.katex .clap>.inner>span{margin-left:-50%;margin-right:50%}.katex .rule{border:0 solid;display:inline-block;position:relative}.katex .hline,.katex .overline .overline-line,.katex .underline .underline-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline{border-bottom-style:dashed;display:inline-block;width:100%}.katex .sqrt>.root{margin-left:.2777777778em;margin-right:-.5555555556em}.katex .fontsize-ensurer.reset-size1.size1,.katex .sizing.reset-size1.size1{font-size:1em}.katex .fontsize-ensurer.reset-size1.size2,.katex .sizing.reset-size1.size2{font-size:1.2em}.katex .fontsize-ensurer.reset-size1.size3,.katex .sizing.reset-size1.size3{font-size:1.4em}.katex .fontsize-ensurer.reset-size1.size4,.katex .sizing.reset-size1.size4{font-size:1.6em}.katex .fontsize-ensurer.reset-size1.size5,.katex .sizing.reset-size1.size5{font-size:1.8em}.katex .fontsize-ensurer.reset-size1.size6,.katex .sizing.reset-size1.size6{font-size:2em}.katex .fontsize-ensurer.reset-size1.size7,.katex .sizing.reset-size1.size7{font-size:2.4em}.katex .fontsize-ensurer.reset-size1.size8,.katex .sizing.reset-size1.size8{font-size:2.88em}.katex .fontsize-ensurer.reset-size1.size9,.katex .sizing.reset-size1.size9{font-size:3.456em}.katex .fontsize-ensurer.reset-size1.size10,.katex .sizing.reset-size1.size10{font-size:4.148em}.katex .fontsize-ensurer.reset-size1.size11,.katex .sizing.reset-size1.size11{font-size:4.976em}.katex .fontsize-ensurer.reset-size2.size1,.katex .sizing.reset-size2.size1{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size2.size2,.katex .sizing.reset-size2.size2{font-size:1em}.katex .fontsize-ensurer.reset-size2.size3,.katex .sizing.reset-size2.size3{font-size:1.1666666667em}.katex .fontsize-ensurer.reset-size2.size4,.katex .sizing.reset-size2.size4{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size2.size5,.katex .sizing.reset-size2.size5{font-size:1.5em}.katex .fontsize-ensurer.reset-size2.size6,.katex .sizing.reset-size2.size6{font-size:1.6666666667em}.katex .fontsize-ensurer.reset-size2.size7,.katex .sizing.reset-size2.size7{font-size:2em}.katex .fontsize-ensurer.reset-size2.size8,.katex .sizing.reset-size2.size8{font-size:2.4em}.katex .fontsize-ensurer.reset-size2.size9,.katex .sizing.reset-size2.size9{font-size:2.88em}.katex .fontsize-ensurer.reset-size2.size10,.katex .sizing.reset-size2.size10{font-size:3.4566666667em}.katex .fontsize-ensurer.reset-size2.size11,.katex .sizing.reset-size2.size11{font-size:4.1466666667em}.katex .fontsize-ensurer.reset-size3.size1,.katex .sizing.reset-size3.size1{font-size:.7142857143em}.katex .fontsize-ensurer.reset-size3.size2,.katex .sizing.reset-size3.size2{font-size:.8571428571em}.katex .fontsize-ensurer.reset-size3.size3,.katex .sizing.reset-size3.size3{font-size:1em}.katex .fontsize-ensurer.reset-size3.size4,.katex .sizing.reset-size3.size4{font-size:1.1428571429em}.katex .fontsize-ensurer.reset-size3.size5,.katex .sizing.reset-size3.size5{font-size:1.2857142857em}.katex .fontsize-ensurer.reset-size3.size6,.katex .sizing.reset-size3.size6{font-size:1.4285714286em}.katex .fontsize-ensurer.reset-size3.size7,.katex .sizing.reset-size3.size7{font-size:1.7142857143em}.katex .fontsize-ensurer.reset-size3.size8,.katex .sizing.reset-size3.size8{font-size:2.0571428571em}.katex .fontsize-ensurer.reset-size3.size9,.katex .sizing.reset-size3.size9{font-size:2.4685714286em}.katex .fontsize-ensurer.reset-size3.size10,.katex .sizing.reset-size3.size10{font-size:2.9628571429em}.katex .fontsize-ensurer.reset-size3.size11,.katex .sizing.reset-size3.size11{font-size:3.5542857143em}.katex .fontsize-ensurer.reset-size4.size1,.katex .sizing.reset-size4.size1{font-size:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .sizing.reset-size4.size3{font-size:.875em}.katex .fontsize-ensurer.reset-size4.size4,.katex .sizing.reset-size4.size4{font-size:1em}.katex .fontsize-ensurer.reset-size4.size5,.katex .sizing.reset-size4.size5{font-size:1.125em}.katex .fontsize-ensurer.reset-size4.size6,.katex .sizing.reset-size4.size6{font-size:1.25em}.katex .fontsize-ensurer.reset-size4.size7,.katex .sizing.reset-size4.size7{font-size:1.5em}.katex .fontsize-ensurer.reset-size4.size8,.katex .sizing.reset-size4.size8{font-size:1.8em}.katex .fontsize-ensurer.reset-size4.size9,.katex .sizing.reset-size4.size9{font-size:2.16em}.katex .fontsize-ensurer.reset-size4.size10,.katex .sizing.reset-size4.size10{font-size:2.5925em}.katex .fontsize-ensurer.reset-size4.size11,.katex .sizing.reset-size4.size11{font-size:3.11em}.katex .fontsize-ensurer.reset-size5.size1,.katex .sizing.reset-size5.size1{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .sizing.reset-size5.size2{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .sizing.reset-size5.size3{font-size:.7777777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .sizing.reset-size5.size4{font-size:.8888888889em}.katex .fontsize-ensurer.reset-size5.size5,.katex .sizing.reset-size5.size5{font-size:1em}.katex .fontsize-ensurer.reset-size5.size6,.katex .sizing.reset-size5.size6{font-size:1.1111111111em}.katex .fontsize-ensurer.reset-size5.size7,.katex .sizing.reset-size5.size7{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size5.size8,.katex .sizing.reset-size5.size8{font-size:1.6em}.katex .fontsize-ensurer.reset-size5.size9,.katex .sizing.reset-size5.size9{font-size:1.92em}.katex .fontsize-ensurer.reset-size5.size10,.katex .sizing.reset-size5.size10{font-size:2.3044444444em}.katex .fontsize-ensurer.reset-size5.size11,.katex .sizing.reset-size5.size11{font-size:2.7644444444em}.katex .fontsize-ensurer.reset-size6.size1,.katex .sizing.reset-size6.size1{font-size:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .sizing.reset-size6.size5{font-size:.9em}.katex .fontsize-ensurer.reset-size6.size6,.katex .sizing.reset-size6.size6{font-size:1em}.katex .fontsize-ensurer.reset-size6.size7,.katex .sizing.reset-size6.size7{font-size:1.2em}.katex .fontsize-ensurer.reset-size6.size8,.katex .sizing.reset-size6.size8{font-size:1.44em}.katex .fontsize-ensurer.reset-size6.size9,.katex .sizing.reset-size6.size9{font-size:1.728em}.katex .fontsize-ensurer.reset-size6.size10,.katex .sizing.reset-size6.size10{font-size:2.074em}.katex .fontsize-ensurer.reset-size6.size11,.katex .sizing.reset-size6.size11{font-size:2.488em}.katex .fontsize-ensurer.reset-size7.size1,.katex .sizing.reset-size7.size1{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .sizing.reset-size7.size3{font-size:.5833333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .sizing.reset-size7.size4{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .sizing.reset-size7.size6{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size7.size7,.katex .sizing.reset-size7.size7{font-size:1em}.katex .fontsize-ensurer.reset-size7.size8,.katex .sizing.reset-size7.size8{font-size:1.2em}.katex .fontsize-ensurer.reset-size7.size9,.katex .sizing.reset-size7.size9{font-size:1.44em}.katex .fontsize-ensurer.reset-size7.size10,.katex .sizing.reset-size7.size10{font-size:1.7283333333em}.katex .fontsize-ensurer.reset-size7.size11,.katex .sizing.reset-size7.size11{font-size:2.0733333333em}.katex .fontsize-ensurer.reset-size8.size1,.katex .sizing.reset-size8.size1{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .sizing.reset-size8.size2{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .sizing.reset-size8.size3{font-size:.4861111111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .sizing.reset-size8.size4{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .sizing.reset-size8.size6{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .sizing.reset-size8.size7{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size8.size8,.katex .sizing.reset-size8.size8{font-size:1em}.katex .fontsize-ensurer.reset-size8.size9,.katex .sizing.reset-size8.size9{font-size:1.2em}.katex .fontsize-ensurer.reset-size8.size10,.katex .sizing.reset-size8.size10{font-size:1.4402777778em}.katex .fontsize-ensurer.reset-size8.size11,.katex .sizing.reset-size8.size11{font-size:1.7277777778em}.katex .fontsize-ensurer.reset-size9.size1,.katex .sizing.reset-size9.size1{font-size:.2893518519em}.katex .fontsize-ensurer.reset-size9.size2,.katex .sizing.reset-size9.size2{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .sizing.reset-size9.size3{font-size:.4050925926em}.katex .fontsize-ensurer.reset-size9.size4,.katex .sizing.reset-size9.size4{font-size:.462962963em}.katex .fontsize-ensurer.reset-size9.size5,.katex .sizing.reset-size9.size5{font-size:.5208333333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .sizing.reset-size9.size6{font-size:.5787037037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .sizing.reset-size9.size7{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .sizing.reset-size9.size8{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size9.size9,.katex .sizing.reset-size9.size9{font-size:1em}.katex .fontsize-ensurer.reset-size9.size10,.katex .sizing.reset-size9.size10{font-size:1.2002314815em}.katex .fontsize-ensurer.reset-size9.size11,.katex .sizing.reset-size9.size11{font-size:1.4398148148em}.katex .fontsize-ensurer.reset-size10.size1,.katex .sizing.reset-size10.size1{font-size:.2410800386em}.katex .fontsize-ensurer.reset-size10.size2,.katex .sizing.reset-size10.size2{font-size:.2892960463em}.katex .fontsize-ensurer.reset-size10.size3,.katex .sizing.reset-size10.size3{font-size:.337512054em}.katex .fontsize-ensurer.reset-size10.size4,.katex .sizing.reset-size10.size4{font-size:.3857280617em}.katex .fontsize-ensurer.reset-size10.size5,.katex .sizing.reset-size10.size5{font-size:.4339440694em}.katex .fontsize-ensurer.reset-size10.size6,.katex .sizing.reset-size10.size6{font-size:.4821600771em}.katex .fontsize-ensurer.reset-size10.size7,.katex .sizing.reset-size10.size7{font-size:.5785920926em}.katex .fontsize-ensurer.reset-size10.size8,.katex .sizing.reset-size10.size8{font-size:.6943105111em}.katex .fontsize-ensurer.reset-size10.size9,.katex .sizing.reset-size10.size9{font-size:.8331726133em}.katex .fontsize-ensurer.reset-size10.size10,.katex .sizing.reset-size10.size10{font-size:1em}.katex .fontsize-ensurer.reset-size10.size11,.katex .sizing.reset-size10.size11{font-size:1.1996142719em}.katex .fontsize-ensurer.reset-size11.size1,.katex .sizing.reset-size11.size1{font-size:.2009646302em}.katex .fontsize-ensurer.reset-size11.size2,.katex .sizing.reset-size11.size2{font-size:.2411575563em}.katex .fontsize-ensurer.reset-size11.size3,.katex .sizing.reset-size11.size3{font-size:.2813504823em}.katex .fontsize-ensurer.reset-size11.size4,.katex .sizing.reset-size11.size4{font-size:.3215434084em}.katex .fontsize-ensurer.reset-size11.size5,.katex .sizing.reset-size11.size5{font-size:.3617363344em}.katex .fontsize-ensurer.reset-size11.size6,.katex .sizing.reset-size11.size6{font-size:.4019292605em}.katex .fontsize-ensurer.reset-size11.size7,.katex .sizing.reset-size11.size7{font-size:.4823151125em}.katex .fontsize-ensurer.reset-size11.size8,.katex .sizing.reset-size11.size8{font-size:.578778135em}.katex .fontsize-ensurer.reset-size11.size9,.katex .sizing.reset-size11.size9{font-size:.6945337621em}.katex .fontsize-ensurer.reset-size11.size10,.katex .sizing.reset-size11.size10{font-size:.8336012862em}.katex .fontsize-ensurer.reset-size11.size11,.katex .sizing.reset-size11.size11{font-size:1em}.katex .delimsizing.size1{font-family:KaTeX_Size1}.katex .delimsizing.size2{font-family:KaTeX_Size2}.katex .delimsizing.size3{font-family:KaTeX_Size3}.katex .delimsizing.size4{font-family:KaTeX_Size4}.katex .delimsizing.mult .delim-size1>span{font-family:KaTeX_Size1}.katex .delimsizing.mult .delim-size4>span{font-family:KaTeX_Size4}.katex .nulldelimiter{display:inline-block;width:.12em}.katex .delimcenter,.katex .op-symbol{position:relative}.katex .op-symbol.small-op{font-family:KaTeX_Size1}.katex .op-symbol.large-op{font-family:KaTeX_Size2}.katex .accent>.vlist-t,.katex .op-limits>.vlist-t{text-align:center}.katex .accent .accent-body{position:relative}.katex .accent .accent-body:not(.accent-full){width:0}.katex .overlay{display:block}.katex .mtable .vertical-separator{display:inline-block;min-width:1px}.katex .mtable .arraycolsep{display:inline-block}.katex .mtable .col-align-c>.vlist-t{text-align:center}.katex .mtable .col-align-l>.vlist-t{text-align:left}.katex .mtable .col-align-r>.vlist-t{text-align:right}.katex .svg-align{text-align:left}.katex svg{fill:currentColor;stroke:currentColor;display:block;height:inherit;position:absolute;width:100%}.katex svg path{stroke:none}.katex svg{fill-rule:nonzero;fill-opacity:1;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1}.katex img{border-style:none;max-height:none;max-width:none;min-height:0;min-width:0}.katex .stretchy{display:block;overflow:hidden;position:relative;width:100%}.katex .stretchy:after,.katex .stretchy:before{content:""}.katex .hide-tail{overflow:hidden;position:relative;width:100%}.katex .halfarrow-left{left:0;overflow:hidden;position:absolute;width:50.2%}.katex .halfarrow-right{overflow:hidden;position:absolute;right:0;width:50.2%}.katex .brace-left{left:0;overflow:hidden;position:absolute;width:25.1%}.katex .brace-center{left:25%;overflow:hidden;position:absolute;width:50%}.katex .brace-right{overflow:hidden;position:absolute;right:0;width:25.1%}.katex .x-arrow-pad{padding:0 .5em}.katex .cd-arrow-pad{padding:0 .55556em 0 .27778em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{border:.04em solid;box-sizing:border-box}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .sout{border-bottom-style:solid;border-bottom-width:.08em}.katex .angl{border-right:.049em solid;border-top:.049em solid;box-sizing:border-box;margin-right:.03889em}.katex .anglpad{padding:0 .03889em}.katex .eqn-num:before{content:"(" counter(katexEqnNo) ")";counter-increment:katexEqnNo}.katex .mml-eqn-num:before{content:"(" counter(mmlEqnNo) ")";counter-increment:mmlEqnNo}.katex .mtr-glue{width:50%}.katex .cd-vert-arrow{display:inline-block;position:relative}.katex .cd-label-left{display:inline-block;position:absolute;right:calc(50% + .3em);text-align:left}.katex .cd-label-right{display:inline-block;left:calc(50% + .3em);position:absolute;text-align:right}.katex-display{display:block;margin:1em 0;text-align:center}.katex-display>.katex{display:block;text-align:center;white-space:nowrap}.katex-display>.katex>.katex-html{display:block;position:relative}.katex-display>.katex>.katex-html>.tag{position:absolute;right:0}.katex-display.leqno>.katex>.katex-html>.tag{left:0;right:auto}.katex-display.fleqn>.katex{padding-left:2em;text-align:left}body{counter-reset:katexEqnNo mmlEqnNo} diff --git a/packages/webui/webapp/test/markdown-math.test.ts b/packages/webui/webapp/test/markdown-math.test.ts new file mode 100644 index 000000000..8626381c3 --- /dev/null +++ b/packages/webui/webapp/test/markdown-math.test.ts @@ -0,0 +1,236 @@ +// webapp/test/markdown-math.test.ts +// +// KaTeX math rendering (lib/math-renderer.ts) — the three input shapes the +// acceptance criteria name, the degradation contract for invalid formulas, +// and the sanitiser policy the math markup depends on. +// +// Test strategy mirrors markdown.test.ts / markdown-registry.test.ts: +// `marked` + KaTeX + the registry are pure string functions, so the emitted +// markup is asserted in Node. The DOM-walking half of the sanitiser and the +// React style handling are verified against a real page in the live +// self-check (browser screenshots) — this file pins the *policy*, not the +// browser. + +import { test, describe, beforeEach, after } from "node:test"; +import assert from "node:assert/strict"; + +import { + ALLOWED_ATTRS, + ALLOWED_TAGS, + isSafeStyleValue, + parseInlineStyle, + parseMarkdown, + _clearLanguageRenderersForTest, +} from "../lib/markdown"; +import { registerMathRenderer } from "../lib/math-renderer"; +import { registerMermaidRenderer } from "../lib/mermaid-renderer"; + +beforeEach(() => { + // Both renderers self-register on module import; the registry is a + // `Map.set`, so re-registering after a clear keeps test order irrelevant. + _clearLanguageRenderersForTest(); + registerMathRenderer(); + registerMermaidRenderer(); +}); + +after(() => { + _clearLanguageRenderersForTest(); +}); + +describe("math: the three input shapes render as formulas", () => { + test("inline `$…$` renders KaTeX markup, not the raw source", () => { + const html = parseMarkdown("energy $E=mc^2$ is conserved"); + assert.match(html, /class="katex"/); + assert.doesNotMatch(html, /katex-display/); + assert.match(html, /^

energy /); + assert.match(html, /is conserved<\/p>\s*$/); + }); + + test("display `$$…$$` renders block-level KaTeX", () => { + const html = parseMarkdown("$$\\frac{a}{b}$$"); + assert.match(html, /class="katex-display"/); + assert.match(html, /class="katex"/); + }); + + test("a ```math fence renders block-level KaTeX, not the codeblock shell", () => { + const html = parseMarkdown("```math\n\\sum_{i=1}^n i = \\frac{n(n+1)}{2}\n```"); + assert.match(html, /class="katex-display"/); + assert.doesNotMatch(html, /codeblock-shell/); + }); + + test("table-driven: valid formulas render through KaTeX in all three shapes", () => { + const formulas = [ + "E=mc^2", + "\\sqrt{2}", + "x^2 + y^2 = z^2", + "\\sum_{i=1}^{n} i", + "\\int_0^1 x\\,dx", + "\\alpha\\beta\\gamma", + "\\begin{pmatrix}a & b\\\\c & d\\end{pmatrix}", + ]; + for (const formula of formulas) { + const inline = parseMarkdown(`$${formula}$`); + assert.match(inline, /class="katex"/, `inline failed: ${formula}`); + const display = parseMarkdown(`$$${formula}$$`); + assert.match(display, /class="katex-display"/, `display failed: ${formula}`); + const fence = parseMarkdown("```math\n" + formula + "\n```"); + assert.match(fence, /class="katex-display"/, `fence failed: ${formula}`); + } + }); + + test("the fence language matches case-insensitively with trailing metadata", () => { + assert.match(parseMarkdown("```MATH\n\\sqrt{2}\n```"), /class="katex-display"/); + assert.match(parseMarkdown("```math {display}\n\\sqrt{2}\n```"), /class="katex-display"/); + }); +}); + +describe("math: invalid formulas degrade to visible source", () => { + test("table-driven: an invalid inline/display formula degrades to inline code with the raw source", () => { + const cases = [ + { source: "$\\sqrt$", raw: "$\\sqrt$" }, + { source: "$\\notacommand$", raw: "$\\notacommand$" }, + { source: "$$\\frac$$", raw: "$$\\frac$$" }, + { source: "$\\left(x\\right$", raw: "$\\left(x\\right$" }, + ]; + for (const { source, raw } of cases) { + const html = parseMarkdown(`before ${source} after`); + assert.match(html, //, `not degraded to code: ${source}`); + assert.ok(html.includes(raw), `raw source not preserved: ${source}`); + assert.doesNotMatch(html, /class="katex"/, `must not render katex: ${source}`); + // The rest of the document is intact around the degraded formula. + assert.match(html, /before /); + assert.match(html, / after/); + } + }); + + test("an invalid ```math fence falls back to the plain codeblock shell with the source", () => { + const html = parseMarkdown("```math\n\\frac{\n```\n\nstill here"); + assert.match(html, /class="codeblock-shell"/); + assert.match(html, /class="codeblock-code language-math"/); + assert.ok(html.includes("\\frac{"), "source must stay visible"); + assert.doesNotMatch(html, /class="katex/); + assert.match(html, /still here/); + }); + + test("a trust-gated command (\\href) cannot smuggle a javascript: URL", () => { + // KaTeX renders `\href` without `trust: true` as a red warning text node + // (no anchor is ever emitted), so no `href` attribute — javascript: or + // otherwise — can reach the DOM through the math path. + const html = parseMarkdown("$\\href{javascript:alert(1)}{x}$"); + assert.doesNotMatch(html, /href\s*=/i); + assert.doesNotMatch(html, /]/); + assert.doesNotMatch(html, /javascript:/); + }); + + test("markup inside a formula is escaped, never executed", () => { + const html = parseMarkdown("$\\text{}$"); + assert.doesNotMatch(html, /