feat: render LaTeX, custom HTML and diagrams in the transcript (#19)

This commit was merged in pull request #19.
This commit is contained in:
2026-10-07 07:26:40 +00:00
parent 550a2bbefe
commit a804afd56a
12 changed files with 2543 additions and 30 deletions
+60
View File
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import {
fenceAsciiArt,
inferLanguage,
labelCodeFences,
mapOutsideCodeSpans,
normalizeFenceLabels,
promoteStandaloneCodeSpans,
repairCodeBlocks,
restoreUrlLinks,
@@ -416,3 +419,60 @@ describe("labelCodeFences", () => {
expect(labelCodeFences(" ```\n ls\n ```")).toContain(" ```bash");
});
});
describe("mapOutsideCodeSpans", () => {
it("applies the transform everywhere a span is not", () => {
expect(mapOutsideCodeSpans("a `b` c", (text) => text.toUpperCase())).toBe("A `b` C");
});
it("matches a delimiter of the same length only", () => {
// A single-backtick span inside a double-backtick span is content, not a
// closer, so the inner one is left exactly as written.
expect(mapOutsideCodeSpans("`` `a` ``", (text) => text.toUpperCase())).toBe("`` `a` ``");
});
it("treats an unclosed run as text and carries on after it", () => {
expect(mapOutsideCodeSpans("a ` b", (text) => text.toUpperCase())).toBe("A ` B");
});
});
describe("normalizeFenceLabels", () => {
it("names a language that resolves in place of one that does not", () => {
expect(normalizeFenceLabels("```console\nls\n```")).toBe("```bash\nls\n```");
});
it("drops a label that carries no information", () => {
expect(normalizeFenceLabels("```text\nplain\n```")).toBe("```\nplain\n```");
});
it("leaves a language the model chose alone", () => {
const input = "```rust\nfn main() {}\n```";
expect(normalizeFenceLabels(input)).toBe(input);
});
});
describe("fenceAsciiArt", () => {
it("fences a tree so its columns stay monospaced", () => {
expect(fenceAsciiArt("src/\n├── a.ts\n└── b.ts")).toBe("```\nsrc/\n├── a.ts\n└── b.ts\n```");
});
it("fences an ASCII table drawn with borders", () => {
const art = "+---+---+\n| a | b |\n+---+---+";
expect(fenceAsciiArt(art)).toBe("```\n" + art + "\n```");
});
it("leaves a real GFM table to the table renderer", () => {
const table = "| a | b |\n| --- | --- |\n| 1 | 2 |";
expect(fenceAsciiArt(table)).toBe(table);
});
it("leaves prose that contains one pipe alone", () => {
const prose = "Here is what it does:\n| a | b |";
expect(fenceAsciiArt(prose)).toBe(prose);
});
it("leaves a diagram that is already fenced alone", () => {
const fenced = "```\n├── a.ts\n└── b.ts\n```";
expect(fenceAsciiArt(fenced)).toBe(fenced);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { sanitizeInlineHtml } from "../lib/html";
describe("sanitizeInlineHtml", () => {
it("keeps the tags a rich text paragraph draws", () => {
expect(sanitizeInlineHtml("<b>bold</b> and <i>italic</i>")).toBe("<b>bold</b> and <i>italic</i>");
expect(sanitizeInlineHtml("x<sup>2</sup> and a<sub>i</sub>")).toBe("x<sup>2</sup> and a<sub>i</sub>");
});
it("keeps a style declaration it can use and drops the rest", () => {
const input = '<span style="color:#ff0000;font-weight:bold;position:absolute">x</span>';
expect(sanitizeInlineHtml(input)).toBe('<span style="color:#ff0000;font-weight:bold">x</span>');
});
it("drops a style value that reaches outside the document", () => {
expect(sanitizeInlineHtml('<span style="background:url(https://x/y.png)">x</span>')).toBe(
"<span>x</span>",
);
expect(sanitizeInlineHtml('<span style="background:#fff">x</span>')).toBe(
'<span style="background:#fff">x</span>',
);
});
it("drops an event handler", () => {
expect(sanitizeInlineHtml('<span onclick="steal()">x</span>')).toBe("<span>x</span>");
});
it("removes a script with the code inside it", () => {
expect(sanitizeInlineHtml("<script>alert(1)</script>ok")).toBe("ok");
expect(sanitizeInlineHtml("<style>p{color:red}</style>ok")).toBe("ok");
expect(sanitizeInlineHtml('<iframe src="https://evil.example"></iframe>ok')).toBe("ok");
expect(sanitizeInlineHtml("<script>alert(1)")).toBe("");
});
it("removes an element with no content of its own, and only the tag", () => {
expect(sanitizeInlineHtml("<input type=text>still here")).toBe("still here");
expect(sanitizeInlineHtml('<meta charset="utf-8">still here')).toBe("still here");
expect(sanitizeInlineHtml("a<br>b")).toBe("a<br>b");
});
it("shows a tag it cannot draw rather than swallowing it", () => {
expect(sanitizeInlineHtml("<foo>bar</foo>")).toBe("&lt;foo&gt;bar&lt;/foo&gt;");
});
it("turns an anchor into the one form that is clickable", () => {
expect(sanitizeInlineHtml('See <a href="https://example.com">the docs</a>.')).toBe(
"See [the docs](https://example.com).",
);
// The destination keeps its query string rather than arriving as `&amp;`.
expect(sanitizeInlineHtml('<a href="https://x/?a=1&b=2">q</a>')).toBe("[q](https://x/?a=1&b=2)");
});
it("keeps the text of an anchor that points somewhere unsafe", () => {
expect(sanitizeInlineHtml('<a href="javascript:alert(1)">click</a>')).toBe("click");
});
it("filters an image source and leaves the tag itself where it is", () => {
// The one place an image written as HTML is loaded for real is the extractor
// for an HTML block, so the tag stays and only its source is judged.
expect(sanitizeInlineHtml('<img src="https://x/y.png" alt="a chart">')).toBe(
'<img src="https://x/y.png" alt="a chart">',
);
expect(sanitizeInlineHtml('<img src="file:///tmp/a.png">')).toBe('<img src="file:///tmp/a.png">');
expect(sanitizeInlineHtml('<img src="javascript:alert(1)">')).toBe("<img>");
expect(sanitizeInlineHtml('<img src="data:image/svg+xml;base64,PHN2Zy8+">')).toBe(
'<img src="data:image/svg+xml;base64,PHN2Zy8+">',
);
});
it("leaves a backtick span exactly as written", () => {
expect(sanitizeInlineHtml("Use `<b>bold</b>` for that.")).toBe("Use `<b>bold</b>` for that.");
});
it("removes a comment", () => {
expect(sanitizeInlineHtml("a<!-- hidden -->b")).toBe("ab");
});
it("leaves a less-than sign that is not a tag alone", () => {
expect(sanitizeInlineHtml("a < b and 3>2")).toBe("a < b and 3>2");
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { looksLikeMath, renderLatex } from "../lib/latex";
describe("renderLatex", () => {
it("prints a command as the character it names", () => {
expect(renderLatex("\\alpha + \\beta \\leq \\infty")).toBe("α + β ≤ ∞");
expect(renderLatex("\\times \\pm \\cdot \\to")).toBe("× ± · →");
});
it("uses a Unicode script where one exists", () => {
expect(renderLatex("x^2")).toBe("x²");
expect(renderLatex("x^{n+1}")).toBe("xⁿ⁺¹");
expect(renderLatex("a_1")).toBe("a₁");
expect(renderLatex("\\sum_{i=1}^{n}")).toBe("∑ᵢ₌₁ⁿ");
});
it("escapes a vertical bar, which would otherwise end a table cell", () => {
expect(renderLatex("\\{x \\mid x > 0\\}")).toBe("{x \\| x \\> 0}");
});
it("falls back to a tag where Unicode has no script form", () => {
expect(renderLatex("x^{a/b}")).toBe("x<sup>a/b</sup>");
expect(renderLatex("a_{i,j}")).toBe("a<sub>i,j</sub>");
expect(renderLatex("\\int_{0}^{\\infty}")).toBe("∫₀<sup>∞</sup>");
});
it("linearises a fraction and brackets only what needs it", () => {
expect(renderLatex("\\frac{1}{2}")).toBe("1/2");
expect(renderLatex("\\frac{a}{b}")).toBe("a/b");
expect(renderLatex("\\frac{a+b}{c}")).toBe("(a+b)/c");
});
it("renders a root, using the root sign for the degree it has", () => {
expect(renderLatex("\\sqrt{x}")).toBe("√x");
expect(renderLatex("\\sqrt{x+1}")).toBe("√(x+1)");
expect(renderLatex("\\sqrt[3]{x}")).toBe("∛x");
});
it("applies an accent as a combining mark", () => {
expect(renderLatex("\\hat{x}")).toBe("x\u0302");
expect(renderLatex("\\bar{y}")).toBe("y\u0304");
expect(renderLatex("\\vec{v}")).toBe("v\u20d7");
});
it("keeps prose inside a text command as prose", () => {
expect(renderLatex("\\text{hello world}")).toBe("hello world");
// `_` is markdown emphasis, so it is escaped even in the middle of prose.
expect(renderLatex("\\text{a_b}")).toBe("a\\_b");
});
it("uses the blackboard and script capitals Unicode has", () => {
expect(renderLatex("\\mathbb{R}")).toBe("ℝ");
expect(renderLatex("\\mathcal{L}")).toBe("ℒ");
expect(renderLatex("\\mathbf{v}")).toBe("<b>v</b>");
});
it("keeps an unknown command readable rather than dropping it", () => {
expect(renderLatex("\\foo{bar}")).toBe("foobar");
});
it("escapes what the markdown parser would otherwise read", () => {
expect(renderLatex("a < b")).toBe("a \\< b");
expect(renderLatex("|x|")).toBe("\\|x\\|");
});
it("breaks a line only in display maths", () => {
expect(renderLatex("a\\\\b", { display: true })).toBe("a<br/>b");
expect(renderLatex("a\\\\b")).toBe("a b");
});
it("drops an alignment rather than printing the ampersand", () => {
expect(renderLatex("\\begin{align} a &= b \\\\ c &= d \\end{align}", { display: true })).toBe(
"a = b <br/> c = d",
);
});
it("guards a formula that would otherwise start a block", () => {
expect(renderLatex("= x", { display: true })).toBe("\\= x");
expect(renderLatex("+ 1", { display: true })).toBe("\\+ 1");
expect(renderLatex("1. x", { display: true })).toBe("1\\. x");
expect(renderLatex("===", { display: true })).toBe("\\===");
});
it("leaves a number that only looks like a marker alone", () => {
// A minus sign becomes the maths minus, so it can never be read as a list,
// and `1.5` is a number rather than an ordered list.
expect(renderLatex("-1", { display: true })).toBe("−1");
expect(renderLatex("- 1", { display: true })).toBe("− 1");
expect(renderLatex("1.5", { display: true })).toBe("1.5");
expect(renderLatex("---", { display: true })).toBe("−−−");
});
});
describe("looksLikeMath", () => {
it("accepts the shapes a model actually writes", () => {
for (const body of ["x^2", "a_1", "\\alpha", "E = mc^2", "\\frac{1}{2}", "2+2", "O(n \\log n)"]) {
expect(looksLikeMath(body)).toBe(true);
}
});
it("rejects money and a shell variable", () => {
for (const body of ["5", "HOME", "PATH", "A; echo B", "a"]) {
expect(looksLikeMath(body)).toBe(false);
}
});
});
+10
View File
@@ -60,6 +60,16 @@ describe("buildThreadMarkdown", () => {
expect(markdown.trim()).toBe("> just a question");
});
it("renders a question through the same pass as an answer", () => {
// A person pastes LaTeX into a question as readily as a model writes it, and
// a question full of raw HTML deserves the same fence.
const math = buildThreadMarkdown([message({ role: "user", text: "what is $x^2$?" })]);
expect(math.trim()).toBe("> what is x²?");
const html = buildThreadMarkdown([message({ role: "user", text: "hi <script>alert(1)</script>" })]);
expect(html.trim()).toBe("> hi");
});
it("groups an answer with its question, dividing only between exchanges", () => {
const markdown = buildThreadMarkdown([
message({ id: "t2:assistant", role: "assistant", text: "second answer" }),
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import { renderInlineContent, renderModelOutput } from "../lib/render";
describe("renderModelOutput", () => {
it("renders inline maths in a sentence", () => {
expect(renderModelOutput("The answer is $x^2 + 1$, obviously.")).toBe("The answer is x² + 1, obviously.");
expect(renderModelOutput("So \\(x^2\\) holds.")).toBe("So x² holds.");
});
it("leaves money and shell variables alone", () => {
expect(renderModelOutput("Costs $5 and $10 today.")).toBe("Costs $5 and $10 today.");
expect(renderModelOutput("cd $HOME && echo $PATH")).toBe("cd $HOME && echo $PATH");
});
it("puts a display formula on a paragraph of its own", () => {
expect(renderModelOutput("$$\\frac{a}{b} = c$$")).toBe("\na/b = c\n");
expect(renderModelOutput("$$\nx = 1\n$$")).toBe("\nx = 1\n");
expect(renderModelOutput("\\[\\sum_{i=1}^{n} i\\]")).toBe("\n∑ᵢ₌₁ⁿ i\n");
});
it("does not let a stray marker swallow the rest of the answer", () => {
// An opening `$$` with no closer only counts when what follows reads as a
// formula, so a price is not a formula and the paragraphs after it survive.
expect(renderModelOutput("The price is\n$$5 per unit.\n\nThat is cheap.")).toBe(
"The price is\n$$5 per unit.\n\nThat is cheap.",
);
});
it("renders a maths fence as a formula rather than as code", () => {
expect(renderModelOutput("```math\n\\sum_{i=1}^{n} i\n```")).toBe("\n∑ᵢ₌₁ⁿ i\n");
expect(renderModelOutput("```latex\nx^2\n```")).toBe("\nx²\n");
});
it("leaves maths inside a fence alone", () => {
const markdown = "```ts\nconst x = $a$;\n```";
expect(renderModelOutput(markdown)).toBe(markdown);
});
it("leaves a dollar inside a code span alone", () => {
expect(renderModelOutput("`$x^2$` stays.")).toBe("`$x^2$` stays.");
});
it("renames a fence language the renderer would print but not colour", () => {
expect(renderModelOutput("```console\nls\n```")).toBe("```bash\nls\n```");
expect(renderModelOutput("```terminal\nls\n```")).toBe("```bash\nls\n```");
});
it("drops a fence label that carries no information", () => {
expect(renderModelOutput("```text\nplain\n```")).toBe("```\nplain\n```");
});
it("leaves a label that does resolve exactly as written", () => {
const markdown = "```python\nprint(1)\n```";
expect(renderModelOutput(markdown)).toBe(markdown);
});
it("fences a diagram so its alignment survives", () => {
expect(renderModelOutput("src/\n├── a.ts\n└── b.ts")).toBe("```\nsrc/\n├── a.ts\n└── b.ts\n```");
});
it("leaves a real table to the table renderer", () => {
const table = "| a | b |\n| --- | --- |\n| 1 | 2 |";
expect(renderModelOutput(table)).toBe(table);
});
it("leaves a diagram inside a fence alone", () => {
const markdown = "```text\n├── a.ts\n└── b.ts\n```";
expect(renderModelOutput(markdown)).toBe("```\n├── a.ts\n└── b.ts\n```");
});
it("filters HTML in the flow of an answer", () => {
expect(renderModelOutput("<script>alert(1)</script>still here")).toBe("still here");
expect(renderModelOutput('<span style="color:#ff0000;position:fixed">x</span>')).toBe(
'<span style="color:#ff0000">x</span>',
);
expect(renderModelOutput('Read <a href="https://example.com">the docs</a>.')).toBe(
"Read [the docs](https://example.com).",
);
});
it("leaves the markup inside a fence as the code it is", () => {
const markdown = "```html\n<b>x</b>\n```";
expect(renderModelOutput(markdown)).toBe(markdown);
});
it("hands a block of HTML to the engine as a paragraph", () => {
// A line that starts with a block tag is an HTML *block*, and Vicinae's block
// path keeps only the text of one. Wrapped in a span it is a paragraph, which
// is the path that keeps raw HTML.
expect(renderModelOutput("<table><tr><td>a</td><td>b</td></tr></table>")).toBe(
"<span><table><tr><td>a</td><td>b</td></tr></table></span>",
);
});
it("joins a block of HTML onto one line", () => {
// `class` is dropped, because there is no stylesheet for it to name, and the
// style attribute is the only thing the renderer reads.
expect(renderModelOutput('<div class="card">\n<b>bold</b>\n</div>')).toBe(
"<span><div> <b>bold</b> </div></span>",
);
});
it("leaves markup that does not balance where the text it carries still shows", () => {
expect(renderModelOutput("<div>hello")).toBe("<div>hello");
});
it("leaves a block holding an image alone, because that path loads it", () => {
expect(renderModelOutput('<div><img src="https://x/y.png"></div>')).toBe(
'<div><img src="https://x/y.png"></div>',
);
});
it("leaves a lone inline tag alone, because it is not a block", () => {
const markdown = "Try <b>bold</b> for that.";
expect(renderModelOutput(markdown)).toBe(markdown);
});
it("keeps an answer that has nothing to render exactly as written", () => {
const plain = "Just a sentence, with a [link](https://example.com) and `code`.";
expect(renderModelOutput(plain)).toBe(plain);
});
it("repairs fences in model output but not in what a person typed", () => {
// A lone command is a fence when a model writes it, and a question about it
// is left as the person typed it.
expect(renderModelOutput("`pwd`")).toBe("```bash\npwd\n```");
expect(renderInlineContent("`pwd`")).toBe("`pwd`");
});
it("still renders maths and markup in what a person typed", () => {
expect(renderInlineContent("what is $x^2$?")).toBe("what is x²?");
expect(renderInlineContent("<script>alert(1)</script>hi")).toBe("hi");
expect(renderInlineContent("```ts\nconst x = $a$;\n```")).toBe("```ts\nconst x = $a$;\n```");
});
});