Files
lam-agent-vicinae/src/__tests__/html.test.ts
T
adrianbonpin 6c9183f7de feat: render LaTeX, custom HTML and diagrams in the transcript
The transcript rendered whatever the model wrote as markdown, and three
things a model writes are not markdown the renderer draws.

LaTeX. Vicinae has no maths block, so `$x^2$` reached the pane as
literal TeX. `latex.ts` converts the subset models actually write into
inline markup the pane can draw: `sup`, `sub`, `b` and `i` are available
because `MdParagraph.qml` is a rich text `TextEdit` and
`markdown-model.cpp` passes `CMARK_NODE_HTML_INLINE` through untouched.
It is a linearisation rather than a typesetter, because the pane is a
launcher-width column: `\frac{a}{b}` becomes `a/b` and `\sqrt{x+1}`
becomes `√(x+1)`, with a Unicode script wherever one exists. A lone `$`
pair only counts when the inside looks like maths, so `$5 and $10` and
`cd $A; echo $B` are left as written.

Custom HTML. Raw HTML is the only route past GFM, and it is also a hole,
so `html.ts` filters it: the tags and style declarations the renderer
acts on are kept, `script` and `style` go with their content, an event
handler, a `url()` in a style value and a `javascript:` destination are
dropped, and a tag that is not on the list is shown as text rather than
swallowed. An `<a href>` becomes a markdown link, the only form Vicinae
makes clickable. A well-formed HTML block is moved into a paragraph,
because the block path keeps only the words of one.

Everything else. `code.ts` now fences a paragraph drawn with box
characters, since a paragraph wraps and a diagram that reflows says
nothing, and renames a fence label KSyntaxHighlighting would print but
never resolve.

The pass lives in `render.ts`, is plain TypeScript with no Vicinae
imports, and the default system prompt now says what the window draws.
2026-10-07 15:26:19 +08:00

82 lines
3.6 KiB
TypeScript

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");
});
});