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.
This commit is contained in:
@@ -16,6 +16,7 @@ Hit a global shortcut, type a question, get a streamed answer without leaving wh
|
||||
- **Persistent conversations.** Every chat is saved locally. The sidebar lists them by recency, and reopening the command picks up where you left off, unless it has been idle longer than you allow.
|
||||
- **Readable code.** Fenced blocks render in a dark panel with the language named in a header, syntax colouring, and a Copy button.
|
||||
- **Real markdown.** Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose.
|
||||
- **Maths, real HTML, and diagrams.** LaTeX renders, so an equation arrives as an equation instead of a wall of backslashes. A small amount of HTML draws as well, for the status chip or tiny grid a sentence is worse at, and a diagram drawn with box characters keeps its alignment instead of being reflowed. See [Rendering](#rendering).
|
||||
- **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching. A turn that uses up its tool budget still ends in an answer, written from what it gathered, rather than a notice that it stopped.
|
||||
- **Tool calls you can watch and stop.** What Lam is doing right now stays on screen, including when it asks the same question twice, the round limit is a preference, Force Stop is always one keystroke away, and a call that repeats itself with identical arguments ends the turn instead of running forever.
|
||||
- **Models, from a list.** Browse every model your host can reach, pick one and start using it immediately, and on a server you run, pull new ones or delete old ones. Tag names are picked, never typed.
|
||||
@@ -262,6 +263,36 @@ The photo clean-up applies exactly the same rule as the sweep, including the one
|
||||
|
||||
When a conversation cannot be read there is no way to know which captures are still in use, so that action says it could not check and deletes nothing, rather than risking a screenshot a surviving chat still shows.
|
||||
|
||||
## Rendering
|
||||
|
||||
The answer is model output, and Vicinae draws a fixed set of things. Everything between the model and the pane happens in one pass, in `render.ts`, and every step of it is there because the renderer cannot do that part alone.
|
||||
|
||||
### LaTeX
|
||||
|
||||
Vicinae has no maths renderer. `MarkdownModel` maps cmark nodes to a fixed set of block components (`MdHeading`, `MdCodeBlock`, `MdTable`, `MdImage` and friends) and there is no maths block among them, so `$x^2$` used to reach the pane as literal TeX. `latex.ts` converts it instead.
|
||||
|
||||
Two facts about the renderer decided the design. A paragraph is a `TextEdit` with `textFormat: RichText` (`MdParagraph.qml`), and `markdown-model.cpp` passes `CMARK_NODE_HTML_INLINE` through untouched, so `sup`, `sub`, `b` and `i` are available. And the pane is a launcher-width column, so a formula has to read linearly: a stacked fraction could not survive a narrow column anyway.
|
||||
|
||||
So this is a linearisation rather than a typesetter. `\frac{a}{b}` becomes `a/b`, `\sqrt{x+1}` becomes `√(x+1)`, `\sum_{i=1}^{n}` becomes `∑ᵢ₌₁ⁿ`, and only what Unicode has no form for falls back to a tag, so `x^{a/b}` becomes `x<sup>a/b</sup>`. `$...$`, `\(...\)`, `$$...$$` or `\[...\]` on a line of its own, and a fence labelled `math`, are all read.
|
||||
|
||||
The one guard is worth knowing about. A lone `$` pair counts as maths only when the inside looks like maths, because `x=$a$b` and `cd $A; echo $B` are not formulas, and turning them into one is the single way this feature can make an answer worse than the dollar signs it replaced. `$5 and $10` is left exactly as written.
|
||||
|
||||
### Custom HTML
|
||||
|
||||
Raw HTML is not merely tolerated by the renderer, it is the only route to anything past what GFM offers: a `<span>` with a colour or a size draws, and so does a table. That is the extension point for a model that would rather show a status chip than describe one, and the default prompt says the capability exists.
|
||||
|
||||
**A block of HTML has to be moved before it can draw anything.** `markdown-model.cpp` has no HTML renderer. An HTML *block* goes through `processHtmlNodes`, which pulls out every `<img>` as a block image and keeps the trimmed text of everything else with the markup thrown away, so `<table><tr><td>a</td><td>b</td></tr></table>` arrives as the word `ab`. A *paragraph*, by contrast, keeps its raw HTML, because `CMARK_NODE_HTML_INLINE` is passed straight through to the `TextEdit`. So a well-formed block is joined onto one line and wrapped in a `<span>`, which is not on CommonMark's list of block tags, and it arrives as the paragraph the rich text engine can read. Two rules keep that rewrite from doing damage: markup that does not balance is left to the block path, which at least keeps the words, and a block holding an `<img>` is left alone entirely, because the extractor is the one place an image written as HTML is loaded for real.
|
||||
|
||||
It is also a hole, because the same route carries whatever else the model writes, so `html.ts` filters it. The tags and style declarations the renderer acts on are kept, `script`, `style` and the other element types whose body is not content are removed with it, and an event handler, a `url()` in a style value and a `javascript:` destination are dropped. An `<a href>` is rewritten as a markdown link, because that is the only form Vicinae makes clickable. An `<img>` keeps its tag and has its source judged instead, since the block extractor reads the attribute rather than any markdown this pass could write, and `data:` counts only when it carries an image.
|
||||
|
||||
What the renderer cannot draw is kept as text rather than dropped, which is the deliberate choice in there. A tag that is not on the list, `<foo>` say, is shown literally: ugly, but honest, and better than a mystery about where the markup went.
|
||||
|
||||
### Everything else
|
||||
|
||||
The rest of the pass is defensive, and stays away from guessing what the model meant. A diagram drawn with box characters is fenced so it keeps its alignment, because `MdParagraph.qml` wraps and a directory tree reflowed is a directory tree scrambled. A fence label KSyntaxHighlighting would print but never resolve is renamed (`console` and `terminal` become `bash`) and one that carries no information at all (`text`, `plaintext`) is dropped, which is what stops a header naming a language nobody highlighted. A fence whose body is all URLs still becomes links, a real GFM table is left to the table renderer, and markup inside a code span is shown rather than applied.
|
||||
|
||||
The whole pass is plain TypeScript with no Vicinae imports, so it unit tests without a runtime, and copying a conversation to the clipboard uses the source text rather than the rendering.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -285,7 +316,10 @@ src/
|
||||
location.ts # time/place, derived locally
|
||||
thread.ts # turns -> flat message list
|
||||
markdown.ts # messages -> detail-panel markdown
|
||||
code.ts # code fences and links, repaired
|
||||
render.ts # the rendering pass: fences, maths, markup
|
||||
latex.ts # TeX -> readable inline markup
|
||||
html.ts # raw HTML -> the subset the renderer draws
|
||||
code.ts # code fences, diagrams and links, repaired
|
||||
preferences.ts # preferences, normalised
|
||||
hooks/useChat.ts # the chat state machine
|
||||
hooks/usePreferences.ts # preferences + the model pick, loaded once
|
||||
@@ -320,7 +354,7 @@ A few decisions worth knowing about before you change things:
|
||||
|
||||
The rendered height stays capped at 200px whatever is asked for (`Math.min(_rawH, 200, maxImageHeight)`), so this is a thumbnail rather than something readable, which is why the action panel still offers Open Screenshot for the full image. The alt text is short for the same reason it is not empty: whatever is there is drawn centred under the image, so a sentence reads as a stray line of prose, and it is the only thing left if the capture file has since been deleted. A preview of an unsent attachment is shown the same way, so a snip is visible before the question about it exists.
|
||||
|
||||
**Code fences are labelled so Vicinae can colour them.** `MdCodeBlock.qml` already provides the dark panel, a header naming the language, and a Copy button, so none of that is built here. The language string is what turns on highlighting: `syntax::highlight` resolves it with `definitionForName(language)` and then `definitionForFileName("file." + language)`. So `bash` and `ts` colour correctly, while an unrecognised string such as `terminal` still renders a label but produces no highlighting at all, so a wrong label is worse than none. `code.ts` therefore labels bare fences conservatively, only when a signal is clear, and the default prompt asks for a language on every fence.
|
||||
**Fences are labelled so Vicinae can colour them, and a diagram is fenced so it stays readable.** `MdCodeBlock.qml` already provides the dark panel, a header naming the language, and a Copy button, so none of that is built here. The language string is what turns on highlighting: `syntax::highlight` resolves it with `definitionForName(language)` and then `definitionForFileName("file." + language)`. So `bash` and `ts` colour correctly, while an unrecognised string such as `terminal` still renders a label but produces no highlighting at all, so a wrong label is worse than none. `code.ts` therefore labels bare fences conservatively, only when a signal is clear, the default prompt asks for a language on every fence, and an alias that resolves to nothing (`console`, `terminal`) is renamed to one that does. The same module fences a paragraph drawn with box characters, because a paragraph wraps and a boxed diagram that reflows is a boxed diagram that no longer says anything.
|
||||
|
||||
**A URL is only clickable as a markdown link, so `code.ts` takes them back out of code.** Vicinae's parser attaches the `table` and `strikethrough` GFM extensions and nothing else, so the `autolink` extension is off: a bare URL is plain dead text. Clickability comes from `MarkdownModel` turning `CMARK_NODE_LINK` into an `<a href>`, which the selection controller opens on release. A URL the model fenced is therefore the worst possible presentation, a dark panel with a Copy button and no way to open the address. `restoreUrlLinks` recovers three narrow shapes (a fence whose entire body is URLs, a lone `` `https://...` `` span, and a line that is only a URL) into `[url](url)` links, and the default prompt now says never to fence a URL in the first place. Anything that is not one of those three shapes is copied through, so a `curl` invocation containing an address is never rewritten.
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("<foo>bar</foo>");
|
||||
});
|
||||
|
||||
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 `&`.
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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```");
|
||||
});
|
||||
});
|
||||
+254
-21
@@ -82,10 +82,14 @@ const FENCE = "```";
|
||||
* bare ``` are the same two lines apart from context, so a pattern that only
|
||||
* matched bare fences treated every closing fence as a new opening one and
|
||||
* swallowed the rest of the answer.
|
||||
*
|
||||
* Exported because the maths and HTML passes have to skip fenced code for the
|
||||
* same reason this file does, and a second copy of the pattern is a second
|
||||
* place to get it wrong.
|
||||
*/
|
||||
const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
|
||||
export const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
|
||||
/** A closing fence is the marker and nothing else. */
|
||||
const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/;
|
||||
export const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/;
|
||||
const STANDALONE_CODE_SPAN = /^\s*`([^`\n]+)`\s*[.!]?\s*$/;
|
||||
|
||||
/**
|
||||
@@ -107,7 +111,7 @@ const STANDALONE_URL_SPAN = /^`([^`\n]+)`[.!]?$/;
|
||||
const LIST_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)(.*)$/;
|
||||
|
||||
/** A GFM table row: it opens and closes with a pipe. */
|
||||
const TABLE_ROW = /^\s*\|.*\|\s*$/;
|
||||
export const TABLE_ROW = /^\s*\|.*\|\s*$/;
|
||||
/**
|
||||
* The delimiter row under a header.
|
||||
*
|
||||
@@ -115,7 +119,72 @@ const TABLE_ROW = /^\s*\|.*\|\s*$/;
|
||||
* column. It is that the line could be nothing else: pipes, dashes, colons and
|
||||
* spaces, with at least one dash to tell it apart from a row of empty cells.
|
||||
*/
|
||||
const TABLE_DELIMITER_ROW = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
||||
export const TABLE_DELIMITER_ROW = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
||||
|
||||
/**
|
||||
* A run of backticks, and where the span it opens ends.
|
||||
*
|
||||
* The run length is part of the delimiter in markdown, and `` `a` `` inside a
|
||||
* double-backtick span is content rather than a closer, so the search is for a
|
||||
* run of exactly the same length. A run with no closer is literal text, so only
|
||||
* the backticks are consumed.
|
||||
*/
|
||||
export function readCodeSpan(text: string, start: number): { raw: string; end: number } {
|
||||
let length = 0;
|
||||
while (text[start + length] === "`") length += 1;
|
||||
|
||||
const marker = "`".repeat(length);
|
||||
let cursor = start + length;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const found = text.indexOf(marker, cursor);
|
||||
if (found === -1) break;
|
||||
|
||||
if (text[found + length] === "`") {
|
||||
cursor = found + length;
|
||||
while (text[cursor] === "`") cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
return { raw: text.slice(start, found + length), end: found + length };
|
||||
}
|
||||
|
||||
return { raw: marker, end: start + length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `transform` to everything outside a code span, leaving the spans
|
||||
* exactly as they were written.
|
||||
*
|
||||
* Every pass that rewrites a message needs this for the same reason: a
|
||||
* `` `x^2` `` or a `` `<b>` `` is something being shown to the reader, not
|
||||
* something to convert. Shared so the rule for what a span is exists once.
|
||||
*/
|
||||
export function mapOutsideCodeSpans(text: string, transform: (text: string) => string): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
let start = 0;
|
||||
let found = false;
|
||||
|
||||
while (index < text.length) {
|
||||
if (text[index] !== "`") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
found = true;
|
||||
const span = readCodeSpan(text, index);
|
||||
out += transform(text.slice(start, index));
|
||||
out += span.raw;
|
||||
index = span.end;
|
||||
start = index;
|
||||
}
|
||||
|
||||
if (!found) return transform(text);
|
||||
|
||||
out += transform(text.slice(start));
|
||||
return out;
|
||||
}
|
||||
|
||||
function significantLines(code: string): string[] {
|
||||
return code
|
||||
@@ -308,22 +377,30 @@ function asBareUrl(candidate: string): string | undefined {
|
||||
return trimmed.replace(TRAILING_SENTENCE_PUNCTUATION, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A markdown link, escaped the way the parser needs it.
|
||||
*
|
||||
* Shared with the HTML pass, which rewrites an `<a href>` into the one shape
|
||||
* Vicinae makes clickable. Square brackets in the *label* would end the link
|
||||
* text early, and a destination containing parentheses (plenty of Wikipedia
|
||||
* articles) has to be wrapped in pointy brackets or the parser reads the first
|
||||
* `)` as the end of the destination and leaves the rest as trailing prose.
|
||||
*/
|
||||
export function markdownLink(label: string, destination: string): string {
|
||||
const escaped = label.replace(/[[\]]/g, "\\$&");
|
||||
const target = /[()\s]/.test(destination) ? `<${destination}>` : destination;
|
||||
return `[${escaped}](${target})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a URL as a markdown link, using the address itself as the label so the
|
||||
* destination stays readable rather than turning into a generic word.
|
||||
*
|
||||
* A destination containing parentheses (plenty of Wikipedia articles) has to
|
||||
* be wrapped in pointy brackets or the parser reads the first `)` as the end of
|
||||
* the destination and leaves the rest as trailing prose.
|
||||
*/
|
||||
function asLink(url: string): string {
|
||||
// Square brackets in the *label* would end the link text early, which is why
|
||||
// an IPv6 literal such as `http://[::1]/` needs them escaped. The destination
|
||||
// tolerates brackets, but not parentheses, which is what the pointy form is
|
||||
// for.
|
||||
const label = url.replace(/[[\]]/g, "\\$&");
|
||||
const destination = /[()]/.test(url) ? `<${url}>` : url;
|
||||
return `[${label}](${destination})`;
|
||||
// An IPv6 literal such as `http://[::1]/` carries the brackets in the label,
|
||||
// which `markdownLink` escapes. The destination tolerates brackets but not
|
||||
// parentheses, which is what the pointy form is for.
|
||||
return markdownLink(url, url);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -455,16 +532,172 @@ export function separateTables(markdown: string): string {
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fence languages that draw a label but no colour, and what to say instead.
|
||||
*
|
||||
* `syntax::highlight` in Vicinae resolves a language with
|
||||
* `definitionForName(language)` and then `definitionForFileName("file." + language)`,
|
||||
* so a name KSyntaxHighlighting has never heard of still gets printed above the
|
||||
* block while producing no highlighting at all. A model reaching for "terminal"
|
||||
* or "console" is describing a shell, so it is told a name that resolves. The
|
||||
* `undefined` entries are the ones that carry no information whatsoever, and a
|
||||
* bare fence is the honest rendering of those.
|
||||
*/
|
||||
const LANGUAGE_ALIASES: Record<string, string | undefined> = {
|
||||
console: "bash",
|
||||
terminal: "bash",
|
||||
shell: "bash",
|
||||
commandline: "bash",
|
||||
"command-line": "bash",
|
||||
none: undefined,
|
||||
output: undefined,
|
||||
plain: undefined,
|
||||
plaintext: undefined,
|
||||
raw: undefined,
|
||||
text: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrites a fence label the renderer would print but not colour.
|
||||
*
|
||||
* A label the model chose is otherwise left alone: this only touches names that
|
||||
* are known not to resolve.
|
||||
*/
|
||||
export function normalizeFenceLabels(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const opener = OPENING_FENCE.exec(lines[index]);
|
||||
if (!opener) {
|
||||
out.push(lines[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, indent, marker, existing] = opener;
|
||||
const key = existing.toLowerCase();
|
||||
|
||||
if (existing && key in LANGUAGE_ALIASES) {
|
||||
const replacement = LANGUAGE_ALIASES[key];
|
||||
out.push(replacement ? `${indent}${marker}${replacement}` : `${indent}${marker}`);
|
||||
} else {
|
||||
out.push(lines[index]);
|
||||
}
|
||||
|
||||
index += 1;
|
||||
while (index < lines.length && !CLOSING_FENCE.test(lines[index])) {
|
||||
out.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (index < lines.length) {
|
||||
out.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Box drawing and block elements, which is what a diagram is drawn with here. */
|
||||
const BOX_DRAWING = /[\u2500-\u257f\u2580-\u259f]/g;
|
||||
|
||||
/**
|
||||
* Whether a line is part of a picture rather than part of a sentence.
|
||||
*
|
||||
* Two box-drawing characters is the clear signal. The ASCII spelling of the same
|
||||
* thing is a line that starts and ends with a border character, which is how a
|
||||
* hand-written `+---+---+` table or an `| a | b |` row begins.
|
||||
*/
|
||||
function isArtLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
|
||||
if ((trimmed.match(BOX_DRAWING)?.length ?? 0) >= 2) return true;
|
||||
|
||||
return /^[+|].*[+|]$/.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a paragraph is a picture that has to keep its alignment.
|
||||
*
|
||||
* Deliberately cautious, because fencing prose is worse than leaving a diagram
|
||||
* to reflow: at least two art lines are required and they have to make up half
|
||||
* the paragraph, a real GFM table is handed to the table renderer instead, and
|
||||
* an indented block is already a code block in markdown.
|
||||
*/
|
||||
function isAsciiArt(paragraph: string[]): boolean {
|
||||
if (paragraph.length < 2) return false;
|
||||
|
||||
const isTable = paragraph.some(
|
||||
(line, index) => TABLE_ROW.test(line) && TABLE_DELIMITER_ROW.test(paragraph[index + 1] ?? ""),
|
||||
);
|
||||
if (isTable) return false;
|
||||
|
||||
if (paragraph.some((line) => /^\s{4,}\S/.test(line))) return false;
|
||||
|
||||
const art = paragraph.filter(isArtLine).length;
|
||||
return art >= 2 && art * 2 >= paragraph.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fences a diagram that was written as plain text.
|
||||
*
|
||||
* A directory tree or a boxed diagram is drawn with spaces that carry the
|
||||
* meaning, and a paragraph reflows them: `MdParagraph.qml` wraps, so the
|
||||
* columns of an ASCII table land wherever the pane width puts them and the
|
||||
* picture arrives scrambled. A fence is monospace and scrolls instead of
|
||||
* wrapping, which is the one rendering that keeps the drawing intact.
|
||||
*/
|
||||
export function fenceAsciiArt(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
if (OPENING_FENCE.test(lines[index])) {
|
||||
index = copyFence(lines, out, index);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A paragraph is the run of non-blank lines, which is the unit that has to
|
||||
// stay together: half a tree in a fence is no better than none of it.
|
||||
let end = index;
|
||||
while (end < lines.length && lines[end].trim().length > 0 && !OPENING_FENCE.test(lines[end])) end += 1;
|
||||
|
||||
const paragraph = lines.slice(index, end);
|
||||
if (isAsciiArt(paragraph)) out.push(FENCE, ...paragraph, FENCE);
|
||||
else out.push(...paragraph);
|
||||
|
||||
if (end === index) {
|
||||
// A blank line, which is already a block boundary.
|
||||
out.push(lines[end]);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
index = end;
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The full pre-render pass, in order.
|
||||
*
|
||||
* Tables are separated first because they change block boundaries: a blank line
|
||||
* has to exist before anything else looks at the line. Links are restored next
|
||||
* so a recovered URL leaves no span for the promoter to fence again. Promotion
|
||||
* then runs before labelling, so a promoted block is seen as an ordinary fence
|
||||
* by the labeller, which is also harmless when promotion already inferred a
|
||||
* language.
|
||||
* has to exist before anything else looks at the line. Diagrams are fenced next,
|
||||
* so every pass after this one sees them as code. Links are restored third so a
|
||||
* recovered URL leaves no span for the promoter to fence again. Promotion then
|
||||
* runs before labelling, so a promoted block is seen as an ordinary fence by the
|
||||
* labeller, which is also harmless when promotion already inferred a language.
|
||||
* Labels are normalised last, which is the only pass that looks at a fence the
|
||||
* model wrote itself.
|
||||
*/
|
||||
export function repairCodeBlocks(markdown: string): string {
|
||||
return labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(separateTables(markdown))));
|
||||
return normalizeFenceLabels(
|
||||
labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(fenceAsciiArt(separateTables(markdown))))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@ Lead with the answer, then the detail. This renders in a narrow window, so stay
|
||||
|
||||
Use the markdown this window actually draws rather than flattening everything into prose. It renders tables, bulleted and numbered lists, headings, blockquotes, bold, italics, inline code and fenced code, so pick the shape that fits. A table is the right shape for comparing three or more things across the same few attributes, so write the table itself: never hand back a table as a bulleted list of rows, and never describe a table you could simply write. Keep it to about four narrow columns so it fits, give it a header row, and leave a blank line before it. A list is for steps or for independent items. Reach for a heading only when an answer genuinely has sections, since at this size a heading usually costs a line and buys nothing.
|
||||
|
||||
Maths is drawn, so write it as LaTeX rather than as flattened text: $x^2$ inside a sentence, and a formula worth a line of its own written as $$...$$. Fractions, roots, sums, integrals, products, Greek letters, and subscripts and superscripts all render. Stacked notation does not, because the window is narrow, so \\frac{a}{b} is drawn as a/b and a matrix comes out linear. Prefer a formula that reads on one line, and keep whole formulas out of code fences.
|
||||
|
||||
A small amount of HTML is drawn too, for the rare case where a visual carries it better than a sentence. Bold, italic, underline, strike, sub, sup and a span with a colour or a size all work, for example <span style="background:#2563eb;color:#fff">new</span>, and a small HTML table is fine. Reach for that only when it earns its place, a status chip or a tiny grid, never to wrap an answer, and still write a comparison as a markdown table. A diagram drawn with characters is fine, but fence it so the columns stay monospaced.
|
||||
|
||||
Write plainly:
|
||||
- Never use an em dash. Use a comma, a colon, or a full stop.
|
||||
- Skip stock service-desk phrasing: "Certainly", "I'd be happy to help", "Great question", "I hope this helps", "Let me know if you need anything else". Warmth that means something beats politeness that means nothing.
|
||||
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
import { mapOutsideCodeSpans, markdownLink } from "./code";
|
||||
|
||||
/**
|
||||
* Custom HTML rendering for the detail panel, and the fence around it.
|
||||
*
|
||||
* Raw HTML is not merely tolerated by Vicinae's markdown renderer, it is the
|
||||
* only route to anything beyond what GFM offers. `markdown-model.cpp` passes
|
||||
* `CMARK_NODE_HTML_INLINE` through unescaped, and `MdParagraph.qml` hands the
|
||||
* result to a `TextEdit` with `textFormat: RichText`, so a `<span>` with a
|
||||
* colour, a `<sup>`, or a small `<table>` really does draw. That is the
|
||||
* extension point for a model that wants to show a status chip or a compact
|
||||
* comparison instead of describing one.
|
||||
*
|
||||
* It is also a hole, because the same route carries whatever else the model
|
||||
* writes: a `<script>` block, an `<img>` pointing anywhere, a `style` attribute
|
||||
* with a `url()` in it. Qt's rich text engine does not run JavaScript, so this
|
||||
* is not the web's threat model, but a stripped tag or a silently swallowed
|
||||
* block still turns a working answer into a confusing one. So the pass is
|
||||
* deliberately two-sided:
|
||||
*
|
||||
* - What the renderer can draw is kept, with attributes and style declarations
|
||||
* filtered to the ones that draw something.
|
||||
* - What it cannot draw is kept as *text* rather than dropped. A model that
|
||||
* wrote `<div class="card">` gets the tags shown literally, which is ugly but
|
||||
* honest, and beats a mystery about where the markup went.
|
||||
* - The one thing removed with its content is content that is not content:
|
||||
* `script`, `style` and the other element types whose body would otherwise
|
||||
* land on screen as prose.
|
||||
*
|
||||
* Two rewrites are worth calling out because they make HTML the *worse* choice
|
||||
* less often. An `<a href>` becomes a markdown link, because Vicinae only makes
|
||||
* a link clickable when the parser sees a markdown destination. An `<img>`
|
||||
* becomes a markdown image, because that is the path that actually resolves a
|
||||
* `file:` or `data:` source and gets an image on screen.
|
||||
*
|
||||
* Callers pass text that contains no fenced code. Fences are the caller's job
|
||||
* because the same scan has to serve the maths pass in `render.ts`, and inline
|
||||
* code spans are skipped here because a `` `<b>` `` is a thing being shown, not
|
||||
* a thing being applied.
|
||||
*/
|
||||
|
||||
/** The tags that draw something in a rich text paragraph. */
|
||||
const ALLOWED_TAGS = new Set([
|
||||
"a",
|
||||
"b",
|
||||
"big",
|
||||
"blockquote",
|
||||
"br",
|
||||
"center",
|
||||
"cite",
|
||||
"code",
|
||||
"dd",
|
||||
"dfn",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"em",
|
||||
"font",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"i",
|
||||
"img",
|
||||
"ins",
|
||||
"kbd",
|
||||
"li",
|
||||
"mark",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"q",
|
||||
"s",
|
||||
"samp",
|
||||
"small",
|
||||
"span",
|
||||
"strike",
|
||||
"strong",
|
||||
"sub",
|
||||
"sup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"tt",
|
||||
"u",
|
||||
"ul",
|
||||
"var",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Element types removed with everything inside them.
|
||||
*
|
||||
* These are the ones whose body is markup, code or fallback text rather than
|
||||
* something to read. `svg` and `math` are here because Qt cannot draw either,
|
||||
* and their children would otherwise spill out as a wall of coordinates.
|
||||
*/
|
||||
const STRIPPED_WITH_CONTENT = new Set([
|
||||
"applet",
|
||||
"audio",
|
||||
"canvas",
|
||||
"dialog",
|
||||
"form",
|
||||
"frameset",
|
||||
"iframe",
|
||||
"math",
|
||||
"noscript",
|
||||
"object",
|
||||
"picture",
|
||||
"script",
|
||||
"select",
|
||||
"style",
|
||||
"svg",
|
||||
"template",
|
||||
"textarea",
|
||||
"title",
|
||||
"video",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Element types removed on their own.
|
||||
*
|
||||
* These have no content of their own, so looking for a closing tag would find
|
||||
* whatever the document happens to contain next and swallow it. `embed` and
|
||||
* `source` sit here for the same reason even though they can be written paired.
|
||||
*/
|
||||
const STRIPPED_EMPTY = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"col",
|
||||
"embed",
|
||||
"frame",
|
||||
"head",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
/** Attributes worth keeping, which is the ones that change how something draws. */
|
||||
const ALLOWED_ATTRIBUTES = new Set([
|
||||
"align",
|
||||
"alt",
|
||||
"bgcolor",
|
||||
"border",
|
||||
"cellpadding",
|
||||
"cellspacing",
|
||||
"color",
|
||||
"colspan",
|
||||
"dir",
|
||||
"face",
|
||||
"height",
|
||||
"href",
|
||||
"rowspan",
|
||||
"size",
|
||||
"src",
|
||||
"style",
|
||||
"title",
|
||||
"valign",
|
||||
"width",
|
||||
]);
|
||||
|
||||
/** Attributes whose value has to be a number, optionally a percentage for the two sizes. */
|
||||
const NUMERIC_ATTRIBUTES = new Set([
|
||||
"border",
|
||||
"cellpadding",
|
||||
"cellspacing",
|
||||
"colspan",
|
||||
"height",
|
||||
"rowspan",
|
||||
"size",
|
||||
"width",
|
||||
]);
|
||||
|
||||
/** Style declarations Qt's rich text engine acts on. */
|
||||
const ALLOWED_STYLE_PROPERTIES = new Set([
|
||||
"background",
|
||||
"background-color",
|
||||
"border",
|
||||
"border-bottom",
|
||||
"border-color",
|
||||
"border-left",
|
||||
"border-right",
|
||||
"border-style",
|
||||
"border-top",
|
||||
"border-width",
|
||||
"color",
|
||||
"font",
|
||||
"font-family",
|
||||
"font-size",
|
||||
"font-style",
|
||||
"font-weight",
|
||||
"letter-spacing",
|
||||
"line-height",
|
||||
"margin",
|
||||
"margin-left",
|
||||
"margin-right",
|
||||
"padding",
|
||||
"padding-left",
|
||||
"padding-right",
|
||||
"text-align",
|
||||
"text-decoration",
|
||||
"vertical-align",
|
||||
"white-space",
|
||||
"width",
|
||||
"height",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The shape of a style value that can do some good.
|
||||
*
|
||||
* Anything reaching outside the document is the point of this test: `url()`,
|
||||
* `expression()` and `javascript:` all pull something in, and none of them can
|
||||
* make a paragraph look better.
|
||||
*/
|
||||
const UNSAFE_STYLE_VALUE = /(url\s*\(|expression\s*\(|javascript:|@import|[<>\\])/i;
|
||||
|
||||
/** A tag, tolerating `>` inside a quoted attribute value. */
|
||||
const TAG = /^<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:\s+(?:[^<>"']|"[^"]*"|'[^']*')*)?)(\/?)>/;
|
||||
|
||||
const ATTRIBUTE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
||||
|
||||
/** A destination a link may point at. */
|
||||
const SAFE_URL = /^(?:https?:|mailto:|tel:|#)/i;
|
||||
|
||||
/**
|
||||
* A source an image may point at.
|
||||
*
|
||||
* Narrower than a link, and deliberately so. A `data:` source counts only when
|
||||
* it carries an image, because `data:text/html` is a document rather than
|
||||
* something to draw, and the extractor that turns an `<img>` inside an HTML
|
||||
* block into a real image block reads this attribute directly.
|
||||
*/
|
||||
const SAFE_IMAGE_URL = /^(?:https?:|file:|data:image\/)/i;
|
||||
|
||||
/** Escape for an HTML attribute, which is also escape enough for an HTML text node. */
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** A tag kept as it was written, shown as text rather than applied. */
|
||||
function asText(raw: string): string {
|
||||
return escapeHtml(raw);
|
||||
}
|
||||
|
||||
function sanitizeStyle(value: string): string | undefined {
|
||||
const kept: string[] = [];
|
||||
|
||||
for (const declaration of value.split(";")) {
|
||||
const colon = declaration.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
|
||||
const property = declaration.slice(0, colon).trim().toLowerCase();
|
||||
const raw = declaration.slice(colon + 1).trim();
|
||||
|
||||
if (!ALLOWED_STYLE_PROPERTIES.has(property)) continue;
|
||||
if (raw.length === 0 || raw.length > 120) continue;
|
||||
if (UNSAFE_STYLE_VALUE.test(raw)) continue;
|
||||
|
||||
kept.push(`${property}:${raw}`);
|
||||
}
|
||||
|
||||
return kept.length > 0 ? kept.join(";") : undefined;
|
||||
}
|
||||
|
||||
function sanitizeAttribute(name: string, value: string): string | undefined {
|
||||
if (name.startsWith("on")) return undefined;
|
||||
if (!ALLOWED_ATTRIBUTES.has(name)) return undefined;
|
||||
|
||||
if (name === "style") return sanitizeStyle(value);
|
||||
|
||||
if (name === "href") {
|
||||
const trimmed = value.trim();
|
||||
return SAFE_URL.test(trimmed) ? trimmed : undefined;
|
||||
}
|
||||
|
||||
if (name === "src") {
|
||||
const trimmed = value.trim();
|
||||
return SAFE_IMAGE_URL.test(trimmed) ? trimmed : undefined;
|
||||
}
|
||||
|
||||
if (NUMERIC_ATTRIBUTES.has(name)) {
|
||||
const trimmed = value.trim();
|
||||
if (/^\d+$/.test(trimmed)) return trimmed;
|
||||
if (/^\d+%$/.test(trimmed) && (name === "width" || name === "height")) return trimmed;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return undefined;
|
||||
|
||||
return escapeHtml(trimmed.slice(0, 120));
|
||||
}
|
||||
|
||||
function renderAttributes(raw: string): string {
|
||||
const attributes: string[] = [];
|
||||
|
||||
for (const match of raw.matchAll(ATTRIBUTE)) {
|
||||
const name = match[1].toLowerCase();
|
||||
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
||||
const sanitized = sanitizeAttribute(name, value);
|
||||
if (sanitized === undefined) continue;
|
||||
attributes.push(`${name}="${sanitized}"`);
|
||||
}
|
||||
|
||||
return attributes.length > 0 ? ` ${attributes.join(" ")}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* One attribute, exactly as it was written.
|
||||
*
|
||||
* Read straight from the tag rather than out of {@link renderAttributes}, because
|
||||
* that pass escapes `&` for HTML and a link destination is not HTML: a query
|
||||
* string should reach the parser as it was, not as `&` and then as an
|
||||
* entity to decode back.
|
||||
*/
|
||||
function readAttribute(raw: string, wanted: string): string | undefined {
|
||||
for (const match of raw.matchAll(ATTRIBUTE)) {
|
||||
if (match[1].toLowerCase() === wanted) return (match[2] ?? match[3] ?? match[4] ?? "").trim();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
/** The tag exactly as written. */
|
||||
raw: string;
|
||||
/** Lower case name, with no leading slash. */
|
||||
name: string;
|
||||
closing: boolean;
|
||||
/** Everything between the name and the closing bracket. */
|
||||
attributes: string;
|
||||
/** Index just past the `>`. */
|
||||
end: number;
|
||||
selfClosing: boolean;
|
||||
}
|
||||
|
||||
function readTag(text: string, start: number): Tag | undefined {
|
||||
const match = TAG.exec(text.slice(start));
|
||||
if (!match) return undefined;
|
||||
|
||||
const raw = match[0];
|
||||
// A `>` inside a quoted value is part of the value, so the match is trusted
|
||||
// and the index is computed from its length rather than from indexOf.
|
||||
if (raw.length > 2000) return undefined;
|
||||
|
||||
return {
|
||||
raw,
|
||||
name: match[2].toLowerCase(),
|
||||
closing: match[1] === "/",
|
||||
attributes: match[3],
|
||||
selfClosing: match[4] === "/",
|
||||
end: start + raw.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The text between an opening tag and its matching close, or the rest of the text. */
|
||||
function findClose(text: string, name: string, start: number): { inner: string; end: number } {
|
||||
const closer = new RegExp(`</${name}\\s*>`, "i");
|
||||
const match = closer.exec(text.slice(start));
|
||||
if (!match) return { inner: text.slice(start), end: text.length };
|
||||
|
||||
return { inner: text.slice(start, start + match.index), end: start + match.index + match[0].length };
|
||||
}
|
||||
|
||||
function renderTag(text: string, tag: Tag): { text: string; end: number } {
|
||||
const { name } = tag;
|
||||
|
||||
if (STRIPPED_EMPTY.has(name)) return { text: "", end: tag.end };
|
||||
|
||||
if (STRIPPED_WITH_CONTENT.has(name)) {
|
||||
if (tag.selfClosing) return { text: "", end: tag.end };
|
||||
const closed = findClose(text, name, tag.end);
|
||||
return { text: "", end: closed.end };
|
||||
}
|
||||
|
||||
// A link is clickable in Vicinae only as a markdown destination, so an anchor
|
||||
// is rewritten rather than passed through. A stray `</a>` is dropped, because
|
||||
// a well-formed pair was already consumed by its opening tag.
|
||||
if (name === "a") {
|
||||
if (tag.closing) return { text: "", end: tag.end };
|
||||
|
||||
const href = readAttribute(tag.attributes, "href");
|
||||
const closed = findClose(text, "a", tag.end);
|
||||
const label = sanitizeInlineHtml(closed.inner).trim();
|
||||
|
||||
if (!href || !SAFE_URL.test(href)) return { text: label, end: closed.end };
|
||||
return { text: markdownLink(label, href), end: closed.end };
|
||||
}
|
||||
|
||||
// An `<img>` needs no branch of its own. It is allowed, so it passes through
|
||||
// with its attributes filtered, and the one place an image written as HTML is
|
||||
// loaded for real is the extractor for an HTML block, which reads that
|
||||
// attribute rather than any markdown this pass could write.
|
||||
if (!ALLOWED_TAGS.has(name)) return { text: asText(tag.raw), end: tag.end };
|
||||
|
||||
const slash = tag.closing ? "/" : "";
|
||||
const attributes = tag.closing ? "" : renderAttributes(tag.attributes);
|
||||
const close = tag.selfClosing ? "/" : "";
|
||||
|
||||
return { text: `<${slash}${name}${attributes}${close}>`, end: tag.end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters raw HTML down to what the detail panel can actually draw.
|
||||
*
|
||||
* The result is markdown, so the tags it keeps are read back by the parser as
|
||||
* inline HTML, and the tags it drops are entities that read as text.
|
||||
*/
|
||||
export function sanitizeInlineHtml(text: string): string {
|
||||
return mapOutsideCodeSpans(text, sanitizeMarkup);
|
||||
}
|
||||
|
||||
/** The tag pass, run on a run of text that holds no code spans. */
|
||||
function sanitizeMarkup(text: string): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < text.length) {
|
||||
if (text.startsWith("<!--", index)) {
|
||||
const close = text.indexOf("-->", index + 4);
|
||||
index = close === -1 ? text.length : close + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (text[index] === "<") {
|
||||
const tag = readTag(text, index);
|
||||
if (tag) {
|
||||
const rendered = renderTag(text, tag);
|
||||
out += rendered.text;
|
||||
index = rendered.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
out += text[index];
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
/**
|
||||
* LaTeX rendering for the detail panel.
|
||||
*
|
||||
* Vicinae has no maths renderer at all. `MarkdownModel` maps cmark nodes to a
|
||||
* fixed set of block components (`MdHeading`, `MdCodeBlock`, `MdTable`,
|
||||
* `MdImage` and friends) and there is no maths block among them, so a formula
|
||||
* the model writes as `$...$` reaches the pane as literal TeX with its
|
||||
* backslashes and braces intact.
|
||||
*
|
||||
* Two facts about the renderer decide the whole design:
|
||||
*
|
||||
* - The output is markdown, and the parts of it written as raw HTML are handed
|
||||
* straight to a `TextEdit` with `textFormat: RichText` (`MdParagraph.qml`
|
||||
* sets exactly that, and `markdown-model.cpp` passes `CMARK_NODE_HTML_INLINE`
|
||||
* through untouched). Qt's rich text engine supports `sup`, `sub`, `b` and
|
||||
* `i`, which is enough for scripts and for the font commands.
|
||||
* - The pane is a launcher-width column. A formula has to read linearly, so
|
||||
* stacked fractions and matrix layouts are out and `a/b` and `√(x)` are in.
|
||||
*
|
||||
* So this is a linearisation rather than a typesetter: it keeps the meaning and
|
||||
* gives up the two-dimensional layout, because a two-dimensional layout cannot
|
||||
* survive a narrow column in the first place.
|
||||
*
|
||||
* Emitted text is escaped for markdown as it is produced, and the tags this
|
||||
* module adds are handed over as HTML, which is what makes a formula safe to
|
||||
* drop into a table cell or a blockquote.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Characters that mean something to markdown, and that a formula must not.
|
||||
*
|
||||
* A backslash escape is the one form the parser strips everywhere: it works in
|
||||
* a paragraph, inside a blockquote, and inside a GFM table cell, where a bare
|
||||
* `|` would end the cell. Everything here is ASCII punctuation, which is
|
||||
* exactly the set CommonMark allows to be escaped.
|
||||
*
|
||||
* Only characters that mean something *anywhere* are here. The ones that only
|
||||
* matter as the first thing on a line (a heading `#`, a list `-`, a setext
|
||||
* underline `=`) are handled by {@link guardBlockStart} instead, so that an
|
||||
* ordinary `a + b` does not come out as `a \+ b`.
|
||||
*/
|
||||
const MARKDOWN_ESCAPE = /[\\`*_[\]<>|&]/g;
|
||||
|
||||
function literal(text: string): string {
|
||||
return text.replace(MARKDOWN_ESCAPE, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers that open a block when they are the first thing on a line.
|
||||
*
|
||||
* A display formula is emitted on its own line, so `- 1` would otherwise turn
|
||||
* into a list and `= x` would turn the line above it into a heading, which is a
|
||||
* lot of damage for a minus sign. A marker needs whitespace or the end of the
|
||||
* line after it, so `-1` and `1.5` are left exactly as they are.
|
||||
*/
|
||||
const BLOCK_START = /^[#>=\-+*](?=\s|$)/;
|
||||
|
||||
/** An ordered list marker, where it is the full stop that has to be escaped. */
|
||||
const ORDERED_START = /^(\d+)([.)])(?=\s|$)/;
|
||||
|
||||
/** A line made only of rule characters, which is a horizontal rule or a setext underline. */
|
||||
const RULE_LINE = /^[-=*_][-=*_ \t]*$/;
|
||||
|
||||
/** Escapes a leading marker that would otherwise be read as block syntax. */
|
||||
function guardBlockStart(formula: string): string {
|
||||
if (RULE_LINE.test(formula)) return `\\${formula}`;
|
||||
if (BLOCK_START.test(formula)) return `\\${formula}`;
|
||||
|
||||
const ordered = ORDERED_START.exec(formula);
|
||||
if (ordered) return `${ordered[1]}\\${ordered[2]}${formula.slice(ordered[0].length)}`;
|
||||
|
||||
return formula;
|
||||
}
|
||||
|
||||
/**
|
||||
* TeX command to character, for the commands that take no argument.
|
||||
*
|
||||
* Functions such as `\sin` map to their own name rather than to a character,
|
||||
* which is the right answer twice over: it is what TeX prints, and the output is
|
||||
* plain text anyway so there is no italic to switch off.
|
||||
*/
|
||||
const SYMBOLS: Record<string, string> = {
|
||||
// Greek, lower case. A capital Greek letter is written as the Latin capital in
|
||||
// TeX, so only the ones whose glyph is not the Latin letter are listed.
|
||||
alpha: "α",
|
||||
beta: "β",
|
||||
gamma: "γ",
|
||||
delta: "δ",
|
||||
epsilon: "ϵ",
|
||||
varepsilon: "ε",
|
||||
zeta: "ζ",
|
||||
eta: "η",
|
||||
theta: "θ",
|
||||
vartheta: "ϑ",
|
||||
iota: "ι",
|
||||
kappa: "κ",
|
||||
varkappa: "ϰ",
|
||||
lambda: "λ",
|
||||
mu: "μ",
|
||||
nu: "ν",
|
||||
xi: "ξ",
|
||||
omicron: "ο",
|
||||
pi: "π",
|
||||
varpi: "ϖ",
|
||||
rho: "ρ",
|
||||
varrho: "ϱ",
|
||||
sigma: "σ",
|
||||
varsigma: "ς",
|
||||
tau: "τ",
|
||||
upsilon: "υ",
|
||||
phi: "ϕ",
|
||||
varphi: "φ",
|
||||
chi: "χ",
|
||||
psi: "ψ",
|
||||
omega: "ω",
|
||||
Gamma: "Γ",
|
||||
Delta: "Δ",
|
||||
Theta: "Θ",
|
||||
Lambda: "Λ",
|
||||
Xi: "Ξ",
|
||||
Pi: "Π",
|
||||
Sigma: "Σ",
|
||||
Upsilon: "Υ",
|
||||
Phi: "Φ",
|
||||
Psi: "Ψ",
|
||||
Omega: "Ω",
|
||||
|
||||
// Binary operators and relations.
|
||||
times: "×",
|
||||
div: "÷",
|
||||
cdot: "·",
|
||||
pm: "±",
|
||||
mp: "∓",
|
||||
ast: "∗",
|
||||
star: "⋆",
|
||||
circ: "∘",
|
||||
bullet: "•",
|
||||
oplus: "⊕",
|
||||
ominus: "⊖",
|
||||
otimes: "⊗",
|
||||
oslash: "⊘",
|
||||
leq: "≤",
|
||||
le: "≤",
|
||||
geq: "≥",
|
||||
ge: "≥",
|
||||
neq: "≠",
|
||||
ne: "≠",
|
||||
equiv: "≡",
|
||||
approx: "≈",
|
||||
sim: "∼",
|
||||
simeq: "≃",
|
||||
cong: "≅",
|
||||
propto: "∝",
|
||||
ll: "≪",
|
||||
gg: "≫",
|
||||
in: "∈",
|
||||
notin: "∉",
|
||||
ni: "∋",
|
||||
subset: "⊂",
|
||||
subseteq: "⊆",
|
||||
supset: "⊃",
|
||||
supseteq: "⊇",
|
||||
cup: "∪",
|
||||
cap: "∩",
|
||||
setminus: "∖",
|
||||
emptyset: "∅",
|
||||
varnothing: "∅",
|
||||
forall: "∀",
|
||||
exists: "∃",
|
||||
nexists: "∄",
|
||||
neg: "¬",
|
||||
lnot: "¬",
|
||||
not: "¬",
|
||||
land: "∧",
|
||||
wedge: "∧",
|
||||
lor: "∨",
|
||||
vee: "∨",
|
||||
perp: "⊥",
|
||||
parallel: "∥",
|
||||
angle: "∠",
|
||||
triangle: "△",
|
||||
square: "□",
|
||||
therefore: "∴",
|
||||
because: "∵",
|
||||
|
||||
// Big operators and the functions that read as words.
|
||||
sum: "∑",
|
||||
prod: "∏",
|
||||
coprod: "∐",
|
||||
int: "∫",
|
||||
iint: "∬",
|
||||
iiint: "∭",
|
||||
oint: "∮",
|
||||
bigcup: "⋃",
|
||||
bigcap: "⋂",
|
||||
bigoplus: "⨁",
|
||||
bigotimes: "⨂",
|
||||
bigvee: "⋁",
|
||||
bigwedge: "⋀",
|
||||
lim: "lim",
|
||||
sup: "sup",
|
||||
inf: "inf",
|
||||
max: "max",
|
||||
min: "min",
|
||||
log: "log",
|
||||
ln: "ln",
|
||||
exp: "exp",
|
||||
sin: "sin",
|
||||
cos: "cos",
|
||||
tan: "tan",
|
||||
cot: "cot",
|
||||
sec: "sec",
|
||||
csc: "csc",
|
||||
arcsin: "arcsin",
|
||||
arccos: "arccos",
|
||||
arctan: "arctan",
|
||||
sinh: "sinh",
|
||||
cosh: "cosh",
|
||||
tanh: "tanh",
|
||||
det: "det",
|
||||
dim: "dim",
|
||||
ker: "ker",
|
||||
gcd: "gcd",
|
||||
mod: "mod",
|
||||
bmod: "mod",
|
||||
pmod: "(mod ",
|
||||
|
||||
// Arrows.
|
||||
to: "→",
|
||||
rightarrow: "→",
|
||||
leftarrow: "←",
|
||||
gets: "←",
|
||||
leftrightarrow: "↔",
|
||||
Rightarrow: "⇒",
|
||||
Leftarrow: "⇐",
|
||||
Leftrightarrow: "⇔",
|
||||
implies: "⟹",
|
||||
iff: "⟺",
|
||||
mapsto: "↦",
|
||||
uparrow: "↑",
|
||||
downarrow: "↓",
|
||||
updownarrow: "↕",
|
||||
nearrow: "↗",
|
||||
searrow: "↘",
|
||||
swarrow: "↙",
|
||||
nwarrow: "↖",
|
||||
longrightarrow: "⟶",
|
||||
longleftarrow: "⟵",
|
||||
hookrightarrow: "↪",
|
||||
rightleftharpoons: "⇌",
|
||||
|
||||
// Everything else worth a glyph.
|
||||
infty: "∞",
|
||||
partial: "∂",
|
||||
nabla: "∇",
|
||||
ldots: "…",
|
||||
dots: "…",
|
||||
cdots: "⋯",
|
||||
vdots: "⋮",
|
||||
ddots: "⋱",
|
||||
degree: "°",
|
||||
deg: "°",
|
||||
prime: "′",
|
||||
ell: "ℓ",
|
||||
hbar: "ℏ",
|
||||
Re: "ℜ",
|
||||
Im: "ℑ",
|
||||
aleph: "ℵ",
|
||||
wp: "℘",
|
||||
imath: "ı",
|
||||
jmath: "ȷ",
|
||||
checkmark: "✓",
|
||||
dagger: "†",
|
||||
ddagger: "‡",
|
||||
S: "§",
|
||||
P: "¶",
|
||||
copyright: "©",
|
||||
langle: "⟨",
|
||||
rangle: "⟩",
|
||||
lfloor: "⌊",
|
||||
rfloor: "⌋",
|
||||
lceil: "⌈",
|
||||
rceil: "⌉",
|
||||
lvert: "|",
|
||||
rvert: "|",
|
||||
mid: "|",
|
||||
vert: "|",
|
||||
lVert: "‖",
|
||||
rVert: "‖",
|
||||
Vert: "‖",
|
||||
"%": "%",
|
||||
$: "$",
|
||||
"&": "&",
|
||||
"#": "#",
|
||||
_: "_",
|
||||
"{": "{",
|
||||
"}": "}",
|
||||
"|": "‖",
|
||||
",": "\u2009",
|
||||
";": "\u2005",
|
||||
":": "\u2005",
|
||||
"!": "",
|
||||
" ": " ",
|
||||
"\\": " ",
|
||||
};
|
||||
|
||||
/**
|
||||
* Commands that ask for a delimiter next, so `\left(` prints as `(`.
|
||||
*
|
||||
* `\left.` is the invisible delimiter TeX uses to pair a one-sided bracket, and
|
||||
* it maps to nothing rather than to a full stop.
|
||||
*/
|
||||
const DELIMITERS: Record<string, string> = {
|
||||
"(": "(",
|
||||
")": ")",
|
||||
"[": "[",
|
||||
"]": "]",
|
||||
"|": "|",
|
||||
".": "",
|
||||
"/": "/",
|
||||
"\\{": "{",
|
||||
"\\}": "}",
|
||||
"\\|": "‖",
|
||||
"\\langle": "⟨",
|
||||
"\\rangle": "⟩",
|
||||
"\\lvert": "|",
|
||||
"\\rvert": "|",
|
||||
"\\lVert": "‖",
|
||||
"\\rVert": "‖",
|
||||
"\\lfloor": "⌊",
|
||||
"\\rfloor": "⌋",
|
||||
"\\lceil": "⌈",
|
||||
"\\rceil": "⌉",
|
||||
};
|
||||
|
||||
/** Two-character ASCII spellings of an operator, which models write in a hurry. */
|
||||
const OPERATOR_SEQUENCES: Record<string, string> = {
|
||||
"<=": "≤",
|
||||
">=": "≥",
|
||||
"!=": "≠",
|
||||
"->": "→",
|
||||
"=>": "⇒",
|
||||
};
|
||||
|
||||
/** Combining marks, applied to the character before them. */
|
||||
const ACCENTS: Record<string, string> = {
|
||||
hat: "\u0302",
|
||||
widehat: "\u0302",
|
||||
bar: "\u0304",
|
||||
overline: "\u0305",
|
||||
vec: "\u20d7",
|
||||
tilde: "\u0303",
|
||||
widetilde: "\u0303",
|
||||
dot: "\u0307",
|
||||
ddot: "\u0308",
|
||||
acute: "\u0301",
|
||||
grave: "\u0300",
|
||||
check: "\u030c",
|
||||
breve: "\u0306",
|
||||
};
|
||||
|
||||
/** Double-struck capitals, which is what `\mathbb` is nearly always used for. */
|
||||
const DOUBLE_STRUCK: Record<string, string> = {
|
||||
A: "𝔸",
|
||||
B: "𝔹",
|
||||
C: "ℂ",
|
||||
D: "𝔻",
|
||||
E: "𝔼",
|
||||
F: "𝔽",
|
||||
G: "𝔾",
|
||||
H: "ℍ",
|
||||
I: "𝕀",
|
||||
J: "𝕁",
|
||||
K: "𝕂",
|
||||
L: "𝕃",
|
||||
M: "𝕄",
|
||||
N: "ℕ",
|
||||
O: "𝕆",
|
||||
P: "ℙ",
|
||||
Q: "ℚ",
|
||||
R: "ℝ",
|
||||
S: "𝕊",
|
||||
T: "𝕋",
|
||||
U: "𝕌",
|
||||
V: "𝕍",
|
||||
W: "𝕎",
|
||||
X: "𝕏",
|
||||
Y: "𝕐",
|
||||
Z: "ℤ",
|
||||
};
|
||||
|
||||
/** The script capitals Unicode actually has. The rest fall back to plain letters. */
|
||||
const SCRIPT: Record<string, string> = {
|
||||
B: "ℬ",
|
||||
E: "ℰ",
|
||||
F: "ℱ",
|
||||
H: "ℋ",
|
||||
I: "ℐ",
|
||||
L: "ℒ",
|
||||
M: "ℳ",
|
||||
R: "ℛ",
|
||||
};
|
||||
|
||||
const SUPERSCRIPTS: Record<string, string> = {
|
||||
"0": "⁰",
|
||||
"1": "¹",
|
||||
"2": "²",
|
||||
"3": "³",
|
||||
"4": "⁴",
|
||||
"5": "⁵",
|
||||
"6": "⁶",
|
||||
"7": "⁷",
|
||||
"8": "⁸",
|
||||
"9": "⁹",
|
||||
"+": "⁺",
|
||||
"−": "⁻",
|
||||
"-": "⁻",
|
||||
"=": "⁼",
|
||||
"(": "⁽",
|
||||
")": "⁾",
|
||||
a: "ᵃ",
|
||||
b: "ᵇ",
|
||||
c: "ᶜ",
|
||||
d: "ᵈ",
|
||||
e: "ᵉ",
|
||||
f: "ᶠ",
|
||||
g: "ᵍ",
|
||||
h: "ʰ",
|
||||
i: "ⁱ",
|
||||
j: "ʲ",
|
||||
k: "ᵏ",
|
||||
l: "ˡ",
|
||||
m: "ᵐ",
|
||||
n: "ⁿ",
|
||||
o: "ᵒ",
|
||||
p: "ᵖ",
|
||||
r: "ʳ",
|
||||
s: "ˢ",
|
||||
t: "ᵗ",
|
||||
u: "ᵘ",
|
||||
v: "ᵛ",
|
||||
w: "ʷ",
|
||||
x: "ˣ",
|
||||
y: "ʸ",
|
||||
z: "ᶻ",
|
||||
};
|
||||
|
||||
const SUBSCRIPTS: Record<string, string> = {
|
||||
"0": "₀",
|
||||
"1": "₁",
|
||||
"2": "₂",
|
||||
"3": "₃",
|
||||
"4": "₄",
|
||||
"5": "₅",
|
||||
"6": "₆",
|
||||
"7": "₇",
|
||||
"8": "₈",
|
||||
"9": "₉",
|
||||
"+": "₊",
|
||||
"−": "₋",
|
||||
"-": "₋",
|
||||
"=": "₌",
|
||||
"(": "₍",
|
||||
")": "₎",
|
||||
a: "ₐ",
|
||||
e: "ₑ",
|
||||
h: "ₕ",
|
||||
i: "ᵢ",
|
||||
j: "ⱼ",
|
||||
k: "ₖ",
|
||||
l: "ₗ",
|
||||
m: "ₘ",
|
||||
n: "ₙ",
|
||||
o: "ₒ",
|
||||
p: "ₚ",
|
||||
r: "ᵣ",
|
||||
s: "ₛ",
|
||||
t: "ₜ",
|
||||
u: "ᵤ",
|
||||
v: "ᵥ",
|
||||
x: "ₓ",
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the inside of a pair of dollar signs is plausibly maths.
|
||||
*
|
||||
* Without this the guard would turn `x=$a$b` and `cd $A; echo $B` into formulas,
|
||||
* which is the one way this feature can make an answer worse than leaving the
|
||||
* dollar signs alone. The test asks for a positive signal rather than merely the
|
||||
* absence of prose, so a bare `$x$` staying as written is the accepted cost.
|
||||
*/
|
||||
export function looksLikeMath(body: string): boolean {
|
||||
if (/\\[a-zA-Z]+/.test(body)) return true; // a TeX command
|
||||
if (/[\^_{}]/.test(body)) return true; // a script or a group
|
||||
if (/[=<>≤≥≠≈∈∑∫√∞∂∇]/.test(body)) return true; // an operator or a symbol
|
||||
if (/\d\s*[-+*/]|[-+*/]\s*\d/.test(body)) return true; // arithmetic on numbers
|
||||
return false;
|
||||
}
|
||||
|
||||
interface Slice {
|
||||
body: string;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface Command {
|
||||
name: string;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Reads `\name` or `\x`, where `x` is anything that is not a letter. */
|
||||
function readCommand(source: string, start: number): Command {
|
||||
const index = start + 1;
|
||||
if (index >= source.length) return { name: "", end: index };
|
||||
|
||||
const letters = /^[a-zA-Z]+/.exec(source.slice(index));
|
||||
if (letters) return { name: letters[0], end: index + letters[0].length };
|
||||
|
||||
return { name: source[index], end: index + 1 };
|
||||
}
|
||||
|
||||
/** Reads a balanced `{...}`, tolerating escapes and an unclosed brace. */
|
||||
function readGroup(source: string, start: number): Slice {
|
||||
let depth = 0;
|
||||
let index = start;
|
||||
|
||||
while (index < source.length) {
|
||||
const char = source[index];
|
||||
|
||||
if (char === "\\") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{") depth += 1;
|
||||
else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return { body: source.slice(start + 1, index), end: index + 1 };
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
// An unbalanced brace is a malformed formula, not a reason to drop the rest of
|
||||
// the answer, so the remainder is treated as the argument.
|
||||
return { body: source.slice(start + 1), end: source.length };
|
||||
}
|
||||
|
||||
/** The next argument: a braced group, a control sequence, or one character. */
|
||||
function readArgument(source: string, start: number): Slice {
|
||||
let index = start;
|
||||
while (index < source.length && /\s/.test(source[index])) index += 1;
|
||||
if (index >= source.length) return { body: "", end: index };
|
||||
|
||||
if (source[index] === "{") return readGroup(source, index);
|
||||
|
||||
if (source[index] === "\\") {
|
||||
const command = readCommand(source, index);
|
||||
return { body: source.slice(index, command.end), end: command.end };
|
||||
}
|
||||
|
||||
return { body: source[index], end: index + 1 };
|
||||
}
|
||||
|
||||
/** Reads `[...]`, the optional argument `\sqrt` uses for the degree. */
|
||||
function readOptional(source: string, start: number): Slice | undefined {
|
||||
let index = start;
|
||||
while (index < source.length && /\s/.test(source[index])) index += 1;
|
||||
if (source[index] !== "[") return undefined;
|
||||
|
||||
const close = source.indexOf("]", index + 1);
|
||||
if (close === -1) return undefined;
|
||||
|
||||
return { body: source.slice(index + 1, close), end: close + 1 };
|
||||
}
|
||||
|
||||
/** Whether an argument needs no brackets beside a slash or a root sign. */
|
||||
function isAtomic(source: string): boolean {
|
||||
const trimmed = source.trim();
|
||||
if (trimmed.length === 0) return true;
|
||||
if (/^[A-Za-z0-9]$/.test(trimmed)) return true;
|
||||
if (/^\d+(\.\d+)?$/.test(trimmed)) return true;
|
||||
return /^\\[a-zA-Z]+$/.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a rendered script to Unicode, or gives up and returns `undefined`.
|
||||
*
|
||||
* The fallback matters: `x^{n+1}` fits in Unicode and reads better for it, while
|
||||
* `x^{abc}` has no Unicode form at all and needs the tag. Backslashes are
|
||||
* stripped before mapping so that an escaped `=` still finds its `⁼`, which is
|
||||
* safe because no Unicode script character is markdown punctuation.
|
||||
*/
|
||||
function mapScript(rendered: string, table: Record<string, string>): string | undefined {
|
||||
const plain = rendered.replace(/\\(.)/g, "$1");
|
||||
let mapped = "";
|
||||
|
||||
for (const char of plain) {
|
||||
const script = table[char];
|
||||
if (!script) return undefined;
|
||||
mapped += script;
|
||||
}
|
||||
|
||||
return mapped.length > 0 ? mapped : undefined;
|
||||
}
|
||||
|
||||
function superscript(rendered: string): string {
|
||||
return mapScript(rendered, SUPERSCRIPTS) ?? `<sup>${rendered}</sup>`;
|
||||
}
|
||||
|
||||
function subscript(rendered: string): string {
|
||||
return mapScript(rendered, SUBSCRIPTS) ?? `<sub>${rendered}</sub>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `\frac{a}{b}` as a slash, with brackets only where they change the meaning.
|
||||
*
|
||||
* A stacked fraction is the thing this cannot do, and `1/2` is what a reader
|
||||
* reconstructs from it anyway. `(a+b)/2` needs the brackets; `\alpha/2` does not.
|
||||
*/
|
||||
function renderFraction(numerator: string, denominator: string, display: boolean): string {
|
||||
const top = convert(numerator, display);
|
||||
const bottom = convert(denominator, display);
|
||||
|
||||
const bracket = (rendered: string, body: string) => (isAtomic(body) ? rendered : `(${rendered})`);
|
||||
return `${bracket(top, numerator)}/${bracket(bottom, denominator)}`;
|
||||
}
|
||||
|
||||
/** `\sqrt{x}` and `\sqrt[3]{x}`, where only a cube and a fourth root have a glyph. */
|
||||
function renderRoot(degree: string | undefined, body: string, display: boolean): string {
|
||||
const radicand = convert(body, display);
|
||||
const wrapped = isAtomic(body) ? radicand : `(${radicand})`;
|
||||
|
||||
if (degree === undefined || degree.trim().length === 0) return `√${wrapped}`;
|
||||
|
||||
const value = degree.trim();
|
||||
if (value === "3") return `∛${wrapped}`;
|
||||
if (value === "4") return `∜${wrapped}`;
|
||||
|
||||
return `${superscript(convert(value, display))}√${wrapped}`;
|
||||
}
|
||||
|
||||
function renderAccent(mark: string, body: string, display: boolean): string {
|
||||
const rendered = convert(body, display);
|
||||
// A combining mark needs a base character in front of it. An empty argument
|
||||
// would put the mark on whatever came before, which is why it is dropped.
|
||||
return rendered.length > 0 ? `${rendered}${mark}` : "";
|
||||
}
|
||||
|
||||
function readDelimiter(source: string, start: number): Slice {
|
||||
let index = start;
|
||||
while (index < source.length && /\s/.test(source[index])) index += 1;
|
||||
if (index >= source.length) return { body: "", end: index };
|
||||
|
||||
if (source[index] === "\\") {
|
||||
const command = readCommand(source, index);
|
||||
const spelled = `\\${command.name}`;
|
||||
return { body: DELIMITERS[spelled] ?? command.name, end: command.end };
|
||||
}
|
||||
|
||||
return { body: DELIMITERS[source[index]] ?? source[index], end: index + 1 };
|
||||
}
|
||||
|
||||
/** The two-character operator spellings, checked before a single character is emitted. */
|
||||
function readOperatorSequence(source: string, index: number): string | undefined {
|
||||
return OPERATOR_SEQUENCES[source.slice(index, index + 2)];
|
||||
}
|
||||
|
||||
function renderCommand(source: string, command: Command, display: boolean): Slice {
|
||||
const { name, end } = command;
|
||||
|
||||
if (name in ACCENTS) {
|
||||
const argument = readArgument(source, end);
|
||||
return { body: renderAccent(ACCENTS[name], argument.body, display), end: argument.end };
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "frac":
|
||||
case "dfrac":
|
||||
case "tfrac":
|
||||
case "cfrac": {
|
||||
const numerator = readArgument(source, end);
|
||||
const denominator = readArgument(source, numerator.end);
|
||||
return { body: renderFraction(numerator.body, denominator.body, display), end: denominator.end };
|
||||
}
|
||||
|
||||
case "sqrt": {
|
||||
const optional = readOptional(source, end);
|
||||
const argument = readArgument(source, optional ? optional.end : end);
|
||||
return { body: renderRoot(optional?.body, argument.body, display), end: argument.end };
|
||||
}
|
||||
|
||||
case "text":
|
||||
case "textrm":
|
||||
case "textnormal":
|
||||
case "textup":
|
||||
case "mbox":
|
||||
case "hbox":
|
||||
case "textsuperscript":
|
||||
case "textsubscript": {
|
||||
// Prose inside maths is prose, so the commands in it are not TeX to
|
||||
// convert: `\text{a_b}` means the literal characters.
|
||||
const argument = readArgument(source, end);
|
||||
const prose = argument.body.replace(/\\([a-zA-Z]+)\s*/g, "$1");
|
||||
return { body: literal(prose), end: argument.end };
|
||||
}
|
||||
|
||||
case "mathbf":
|
||||
case "boldsymbol":
|
||||
case "bm":
|
||||
case "bf":
|
||||
case "pmb": {
|
||||
const argument = readArgument(source, end);
|
||||
return { body: `<b>${convert(argument.body, display)}</b>`, end: argument.end };
|
||||
}
|
||||
|
||||
case "mathrm":
|
||||
case "mathsf":
|
||||
case "mathtt":
|
||||
case "mathord":
|
||||
case "mathop":
|
||||
case "mathbin":
|
||||
case "mathrel":
|
||||
case "mathpunct":
|
||||
case "operatorname": {
|
||||
// Upright by definition, and the panel has no italic to switch off, so
|
||||
// the argument is converted for its symbols and left as it reads.
|
||||
const argument = readArgument(source, end);
|
||||
return { body: convert(argument.body, display), end: argument.end };
|
||||
}
|
||||
|
||||
case "mathit":
|
||||
case "textit":
|
||||
case "it":
|
||||
case "emph": {
|
||||
const argument = readArgument(source, end);
|
||||
return { body: `<i>${convert(argument.body, display)}</i>`, end: argument.end };
|
||||
}
|
||||
|
||||
case "mathbb": {
|
||||
const argument = readArgument(source, end);
|
||||
const trimmed = argument.body.trim();
|
||||
const glyph = DOUBLE_STRUCK[trimmed];
|
||||
return { body: glyph ? literal(glyph) : convert(argument.body, display), end: argument.end };
|
||||
}
|
||||
|
||||
case "mathcal":
|
||||
case "mathscr":
|
||||
case "mathfrak": {
|
||||
const argument = readArgument(source, end);
|
||||
const trimmed = argument.body.trim();
|
||||
const glyph = SCRIPT[trimmed];
|
||||
return { body: glyph ? literal(glyph) : convert(argument.body, display), end: argument.end };
|
||||
}
|
||||
|
||||
case "left":
|
||||
case "right":
|
||||
case "middle": {
|
||||
const delimiter = readDelimiter(source, end);
|
||||
return { body: literal(delimiter.body), end: delimiter.end };
|
||||
}
|
||||
|
||||
case "\\":
|
||||
// A line break in a display formula, and a space when the same source is
|
||||
// read inline, where breaking the line would split a sentence.
|
||||
return { body: display ? "<br/>" : " ", end };
|
||||
|
||||
case "begin":
|
||||
case "end":
|
||||
case "displaystyle":
|
||||
case "textstyle":
|
||||
case "scriptstyle":
|
||||
case "scriptscriptstyle":
|
||||
case "limits":
|
||||
case "nolimits":
|
||||
case "nonumber":
|
||||
case "notag":
|
||||
case "quad":
|
||||
case "qquad":
|
||||
case "enspace":
|
||||
case "thinspace":
|
||||
case "negthinspace":
|
||||
case "allowbreak":
|
||||
return { body: "", end: name === "begin" || name === "end" ? readArgument(source, end).end : end };
|
||||
|
||||
case "hspace":
|
||||
case "mspace":
|
||||
case "kern":
|
||||
case "phantom":
|
||||
case "hphantom":
|
||||
case "vphantom":
|
||||
return { body: " ", end: readArgument(source, end).end };
|
||||
|
||||
case "overset":
|
||||
case "underset":
|
||||
case "stackrel": {
|
||||
// Two arguments and no way to stack them here, so the annotation is
|
||||
// printed after the thing it annotates rather than over it.
|
||||
const annotation = readArgument(source, end);
|
||||
const base = readArgument(source, annotation.end);
|
||||
return {
|
||||
body: `${convert(base.body, display)}${superscript(convert(annotation.body, display))}`,
|
||||
end: base.end,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (name in SYMBOLS) return { body: literal(SYMBOLS[name]), end };
|
||||
|
||||
// An unknown command is a command this codebase does not know, not a reason to
|
||||
// lose the formula: the name is printed so the reader still sees what was
|
||||
// written. `\foo{x}` prints as `foo`, and the argument follows on its own.
|
||||
return { body: literal(name), end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts one run of TeX. Recursive, because an argument is TeX too.
|
||||
*
|
||||
* `display` only changes how `\\` is read: in a block it is a line break, and
|
||||
* inline it would split a sentence in half.
|
||||
*/
|
||||
function convert(source: string, display: boolean): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < source.length) {
|
||||
const char = source[index];
|
||||
|
||||
if (char === "\\") {
|
||||
const command = readCommand(source, index);
|
||||
const rendered = renderCommand(source, command, display);
|
||||
out += rendered.body;
|
||||
index = rendered.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{") {
|
||||
const group = readGroup(source, index);
|
||||
out += convert(group.body, display);
|
||||
index = group.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "}") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "^" || char === "_") {
|
||||
const argument = readArgument(source, index + 1);
|
||||
const rendered = convert(argument.body, display);
|
||||
out += char === "^" ? superscript(rendered) : subscript(rendered);
|
||||
index = argument.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
// `&` is a column separator in an alignment, and dropping it is right for
|
||||
// the `x &= 1` spelling because the spaces around it are already there.
|
||||
if (char === "&") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sequence = readOperatorSequence(source, index);
|
||||
if (sequence) {
|
||||
out += literal(sequence);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
out += literal(char === "-" ? "−" : char);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface LatexOptions {
|
||||
/** Display maths stands on its own line, so `\\` becomes a line break. */
|
||||
display?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a LaTeX fragment as inline markup the detail panel can draw.
|
||||
*
|
||||
* The result is markdown, not plain text: it contains HTML tags for anything
|
||||
* Unicode cannot express, and backslash escapes around everything the formula
|
||||
* must not let the markdown parser read. Callers insert it as-is.
|
||||
*/
|
||||
export function renderLatex(source: string, options: LatexOptions = {}): string {
|
||||
const rendered = convert(source, options.display ?? false).trim();
|
||||
return options.display ? guardBlockStart(rendered) : rendered;
|
||||
}
|
||||
+10
-7
@@ -1,5 +1,5 @@
|
||||
import { imageSource } from "./attachments";
|
||||
import { repairCodeBlocks } from "./code";
|
||||
import { renderInlineContent, renderModelOutput } from "./render";
|
||||
import { describeActivity } from "./thread";
|
||||
import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types";
|
||||
|
||||
@@ -117,10 +117,13 @@ function renderToolFailureNote(message: ThreadMessage): string | undefined {
|
||||
}
|
||||
|
||||
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
|
||||
// The question is identified by its styling alone.
|
||||
// The question is identified by its styling alone. It goes through the same
|
||||
// rendering pass as an answer, because a person pastes LaTeX into a question
|
||||
// as readily as a model writes it, and a question full of raw HTML should be
|
||||
// put behind the same fence.
|
||||
if (message.role === "user") {
|
||||
const images = renderAttachments(message.attachments);
|
||||
const asked = quote(message.text);
|
||||
const asked = quote(renderInlineContent(message.text));
|
||||
|
||||
return images ? `${asked}\n\n${images}` : asked;
|
||||
}
|
||||
@@ -148,10 +151,10 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
|
||||
/** The answer, or whatever stands in for it while there is none. */
|
||||
const answered = message.text.trim();
|
||||
const body = answered
|
||||
? // Fences without a language render as a plain dark panel, and a lone
|
||||
// inline command renders as a chip with no Copy button at all. Both are
|
||||
// repaired before the markdown reaches the renderer.
|
||||
repairCodeBlocks(answered)
|
||||
? // One pass, in one place: fences get their languages and links recovered,
|
||||
// then TeX becomes something drawable and the HTML around it is filtered to
|
||||
// the subset the renderer acts on. See `render.ts` for the order and why.
|
||||
renderModelOutput(answered)
|
||||
: !message.isStreaming
|
||||
? "_No response._"
|
||||
: activityLine
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
import { CLOSING_FENCE, OPENING_FENCE, mapOutsideCodeSpans, readCodeSpan, repairCodeBlocks } from "./code";
|
||||
import { sanitizeInlineHtml } from "./html";
|
||||
import { looksLikeMath, renderLatex } from "./latex";
|
||||
|
||||
/**
|
||||
* The rendering pass for model output.
|
||||
*
|
||||
* `markdown.ts` builds the transcript, but an answer is not markdown that the
|
||||
* renderer can draw as written. Models write LaTeX, raw HTML, and diagrams made
|
||||
* of spaces, and Vicinae draws none of those: it has no maths block, block level
|
||||
* HTML is reduced to its text, and a paragraph wraps. Each of those is turned
|
||||
* into something the renderer already knows how to draw here, in one place, on
|
||||
* the way to the pane.
|
||||
*
|
||||
* The order is the whole design:
|
||||
*
|
||||
* 1. {@link repairCodeBlocks} runs first, because it decides where the fences
|
||||
* are. Everything after this pass is fence aware and skips code.
|
||||
* 2. Maths runs next, so a formula becomes inline markup before the HTML pass
|
||||
* looks at markup at all.
|
||||
* 3. HTML runs last, so the tags the maths pass adds are checked by the same
|
||||
* allowlist as the ones the model wrote. It is a second opinion on this
|
||||
* module's own output, and it costs nothing to take.
|
||||
*
|
||||
* Nothing here reads preferences or touches Vicinae, so the whole pipeline is
|
||||
* unit testable and the clipboard copy in `markdown.ts` keeps the source text
|
||||
* rather than the rendering.
|
||||
*/
|
||||
|
||||
/** Fence labels that mean "this body is maths". */
|
||||
const MATH_LANGUAGES = new Set(["math", "maths", "latex", "tex", "stex", "katex"]);
|
||||
|
||||
interface Segment {
|
||||
/** Fenced code, which every pass here leaves alone unless it is maths. */
|
||||
fence: boolean;
|
||||
/** Lower-cased language on the opening fence, empty when there is none. */
|
||||
language: string;
|
||||
/** For a fence, the body lines only. For text, the lines themselves. */
|
||||
lines: string[];
|
||||
/** A fence exactly as written, so it can be copied through untouched. */
|
||||
raw?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a document into text and fences.
|
||||
*
|
||||
* Both passes need the same answer to "is this code", and this is the one place
|
||||
* that decides. A fence with no closing marker runs to the end of the document,
|
||||
* which is how the parser reads it too.
|
||||
*/
|
||||
function splitSegments(markdown: string): Segment[] {
|
||||
const lines = markdown.split("\n");
|
||||
const segments: Segment[] = [];
|
||||
|
||||
let text: string[] = [];
|
||||
const flushText = () => {
|
||||
if (text.length > 0) segments.push({ fence: false, language: "", lines: text });
|
||||
text = [];
|
||||
};
|
||||
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const opener = OPENING_FENCE.exec(lines[index]);
|
||||
|
||||
if (!opener) {
|
||||
text.push(lines[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushText();
|
||||
|
||||
const raw = [lines[index]];
|
||||
index += 1;
|
||||
while (index < lines.length && !CLOSING_FENCE.test(lines[index])) {
|
||||
raw.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) {
|
||||
raw.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
fence: true,
|
||||
language: opener[3].toLowerCase(),
|
||||
lines: raw.slice(1, raw.length - (CLOSING_FENCE.test(raw[raw.length - 1]) ? 1 : 0)),
|
||||
raw,
|
||||
});
|
||||
}
|
||||
|
||||
flushText();
|
||||
return segments;
|
||||
}
|
||||
|
||||
const DISPLAY_MARKERS = ["$$", "\\["];
|
||||
|
||||
const DISPLAY_CLOSERS: Record<string, string> = { $$: "$$", "\\[": "\\]" };
|
||||
|
||||
interface DisplayOpen {
|
||||
marker: string;
|
||||
/** The formula on this line, whether or not the marker closed it here. */
|
||||
body: string;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a display maths opener, if this line has one.
|
||||
*
|
||||
* A marker is only read as a block opener when it is the first thing on the
|
||||
* line, so `where $$x$$ is` stays a sentence. What follows the marker is the
|
||||
* formula whether or not the closer is on the same line, because a long formula
|
||||
* written across lines puts its first terms beside the opening `$$`.
|
||||
*/
|
||||
function openDisplayMath(line: string): DisplayOpen | undefined {
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
for (const marker of DISPLAY_MARKERS) {
|
||||
if (!trimmed.startsWith(marker)) continue;
|
||||
|
||||
const closer = DISPLAY_CLOSERS[marker];
|
||||
const inner = trimmed.slice(marker.length).trim();
|
||||
|
||||
if (inner.length >= closer.length && inner.endsWith(closer)) {
|
||||
return { marker, body: inner.slice(0, inner.length - closer.length), closed: true };
|
||||
}
|
||||
|
||||
return { marker, body: inner, closed: false };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The part of a line before the closer, or `undefined` when the line has none. */
|
||||
function closeDisplayMath(line: string, marker: string): string | undefined {
|
||||
const closer = DISPLAY_CLOSERS[marker];
|
||||
const trimmed = line.trimEnd();
|
||||
return trimmed.endsWith(closer) ? trimmed.slice(0, trimmed.length - closer.length) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline maths, in the order the delimiters have to be tried.
|
||||
*
|
||||
* `$$` has to be read before `$`, and both have to be read after the backslash
|
||||
* forms, which are unambiguous. The single `$` pattern is the fussy one and the
|
||||
* guard around it is deliberate: an opening `$` cannot be followed by a space
|
||||
* and a closing `$` cannot be preceded by one, which is what keeps `$5 and $10`
|
||||
* and `cd $A; echo $B` out of the renderer.
|
||||
*/
|
||||
const INLINE_MATH: Array<{ pattern: RegExp; guarded: boolean }> = [
|
||||
{ pattern: /\\\((.+?)\\\)/g, guarded: false },
|
||||
{ pattern: /\\\[(.+?)\\\]/g, guarded: false },
|
||||
{ pattern: /\$\$(.+?)\$\$/g, guarded: false },
|
||||
{ pattern: /(?<![$\\])\$(?![\s$])((?:\\.|[^\n$\\])+?)(?<!\s)\$(?!\$|\d)/g, guarded: true },
|
||||
];
|
||||
|
||||
function convertInlineMath(text: string): string {
|
||||
return mapOutsideCodeSpans(text, (chunk) => {
|
||||
let out = chunk;
|
||||
|
||||
for (const { pattern, guarded } of INLINE_MATH) {
|
||||
out = out.replace(pattern, (match: string, body: string) =>
|
||||
!guarded || looksLikeMath(body) ? renderLatex(body) : match,
|
||||
);
|
||||
}
|
||||
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The line with every code span blanked out, at the same length.
|
||||
*
|
||||
* Used to ask whether a display marker is really at the start of the line. The
|
||||
* replacement is a non-space character on purpose: spaces would be trimmed away
|
||||
* again and a marker hidden behind a code span would look like a block opener.
|
||||
*/
|
||||
function maskCodeSpans(line: string): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < line.length) {
|
||||
if (line[index] !== "`") {
|
||||
out += line[index];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = readCodeSpan(line, index);
|
||||
out += "\u0000".repeat(span.raw.length);
|
||||
index = span.end;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the maths in a run of text.
|
||||
*
|
||||
* A display formula is pulled onto its own paragraph with a blank line either
|
||||
* side, which is the only way to keep it out of the sentence around it and out
|
||||
* of the paragraph that follows.
|
||||
*/
|
||||
function renderMathLines(lines: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
const display = openDisplayMath(line);
|
||||
|
||||
// A marker inside a code span is being shown, not used, and it is the
|
||||
// opening one that decides whether this is a block at all.
|
||||
if (!display || !openDisplayMath(maskCodeSpans(line))) {
|
||||
out.push(convertInlineMath(line));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (display.closed) {
|
||||
const formula = renderLatex(display.body, { display: true });
|
||||
if (formula.length > 0) out.push("", formula, "");
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = [display.body];
|
||||
let cursor = index + 1;
|
||||
let closed = false;
|
||||
|
||||
while (cursor < lines.length) {
|
||||
const rest = closeDisplayMath(lines[cursor], display.marker);
|
||||
if (rest !== undefined) {
|
||||
body.push(rest);
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
body.push(lines[cursor]);
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
// A marker with no closer is only a formula if what it swallowed looks like
|
||||
// one. A stray `$$5 per unit` would otherwise pull the rest of the answer
|
||||
// into a single paragraph, which is a lot of damage for a typo.
|
||||
if (!closed && !looksLikeMath(body.join("\n"))) {
|
||||
out.push(convertInlineMath(line));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const formula = renderLatex(body.join("\n"), { display: true });
|
||||
if (formula.length > 0) out.push("", formula, "");
|
||||
|
||||
// An unclosed display marker swallows the rest of the run, which is what the
|
||||
// formula the model started to write actually covers.
|
||||
index = closed ? cursor + 1 : lines.length;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function mathPass(segments: Segment[]): Segment[] {
|
||||
return segments.map((segment) => {
|
||||
if (segment.fence) {
|
||||
if (!MATH_LANGUAGES.has(segment.language)) return segment;
|
||||
|
||||
const formula = renderLatex(segment.lines.join("\n"), { display: true });
|
||||
return { fence: false, language: "", lines: formula.length > 0 ? ["", formula, ""] : [] };
|
||||
}
|
||||
|
||||
return { fence: false, language: "", lines: renderMathLines(segment.lines) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags a parser reads as an HTML *block* when a line starts with one.
|
||||
*
|
||||
* This is CommonMark's type 6 list. An HTML block is not a rendering of its own
|
||||
* in Vicinae: `processHtmlNodes` in `markdown-model.cpp` walks it, pulls out
|
||||
* every `<img>` as a block image, and keeps the trimmed text of everything else
|
||||
* with the markup thrown away. So `<table><tr><td>a</td><td>b</td></tr></table>`
|
||||
* arrives as the word `ab`, and a `<div>` full of styled spans arrives as one
|
||||
* run-on sentence.
|
||||
*/
|
||||
const HTML_BLOCK_TAGS = new Set([
|
||||
"address",
|
||||
"article",
|
||||
"aside",
|
||||
"base",
|
||||
"basefont",
|
||||
"blockquote",
|
||||
"body",
|
||||
"caption",
|
||||
"center",
|
||||
"col",
|
||||
"colgroup",
|
||||
"dd",
|
||||
"details",
|
||||
"dialog",
|
||||
"dir",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"fieldset",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"footer",
|
||||
"form",
|
||||
"frame",
|
||||
"frameset",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"head",
|
||||
"header",
|
||||
"hr",
|
||||
"html",
|
||||
"iframe",
|
||||
"legend",
|
||||
"li",
|
||||
"link",
|
||||
"main",
|
||||
"menu",
|
||||
"menuitem",
|
||||
"nav",
|
||||
"noframes",
|
||||
"ol",
|
||||
"optgroup",
|
||||
"option",
|
||||
"p",
|
||||
"param",
|
||||
"search",
|
||||
"section",
|
||||
"summary",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"title",
|
||||
"tr",
|
||||
"track",
|
||||
"ul",
|
||||
]);
|
||||
|
||||
/** Tags that never need a closing tag, which the balance check has to skip. */
|
||||
const VOID_TAGS = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
/** Whether this line would open an HTML block rather than continue a paragraph. */
|
||||
function startsHtmlBlock(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
const name = /^<([a-zA-Z][a-zA-Z0-9-]*)[\s/>]/.exec(trimmed)?.[1]?.toLowerCase();
|
||||
if (name && HTML_BLOCK_TAGS.has(name)) return true;
|
||||
|
||||
// A single complete tag with nothing but whitespace after it opens a block
|
||||
// too, which is the rule that catches a standalone `<img>` or `<span>`.
|
||||
return /^<\/?[a-zA-Z][a-zA-Z0-9-]*(\s[^<>]*)?\/?>\s*$/.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every tag in this run of HTML is closed in the right order.
|
||||
*
|
||||
* Only well-formed markup is handed to the rich text engine, because markup that
|
||||
* does not balance is markup nothing can be relied on to draw, and the text the
|
||||
* block path keeps is the better of two broken outcomes.
|
||||
*/
|
||||
function isBalancedHtml(text: string): boolean {
|
||||
const stack: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)[^<>]*?(\/?)>/g)) {
|
||||
const name = match[2].toLowerCase();
|
||||
if (match[3] === "/" || VOID_TAGS.has(name)) continue;
|
||||
|
||||
if (match[1] === "/") {
|
||||
if (stack.pop() !== name) return false;
|
||||
} else {
|
||||
stack.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a paragraph should be handed to the engine as inline HTML.
|
||||
*
|
||||
* The `<img>` exception is deliberate: an image inside an HTML block is the one
|
||||
* thing that path does better than this one, because it becomes a block image
|
||||
* the renderer actually loads.
|
||||
*/
|
||||
function shouldUnblock(paragraph: string[]): boolean {
|
||||
if (paragraph.length === 0) return false;
|
||||
if (!startsHtmlBlock(paragraph[0])) return false;
|
||||
|
||||
const text = paragraph.join("\n");
|
||||
if (/<img\b/i.test(text)) return false;
|
||||
|
||||
return isBalancedHtml(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a block of HTML into the paragraph the engine can see.
|
||||
*
|
||||
* Wrapping it in a `<span>` and collapsing it onto one line is what makes the
|
||||
* difference: a line that begins with `<span>` is a paragraph rather than an
|
||||
* HTML block, because `span` is not on that list, and a paragraph keeps its raw
|
||||
* HTML (`CMARK_NODE_HTML_INLINE` is passed through untouched). The same markup
|
||||
* therefore reaches the rich text engine, which is the only component in the
|
||||
* chain that can draw a table or a styled panel at all.
|
||||
*
|
||||
* Lines are joined because a continuation line starting with a block tag would
|
||||
* open a new HTML block of its own, which is how a multi-line HTML table loses
|
||||
* its markup today.
|
||||
*/
|
||||
function unblockHtml(lines: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
let end = index;
|
||||
while (end < lines.length && lines[end].trim().length > 0) end += 1;
|
||||
|
||||
const paragraph = lines.slice(index, end);
|
||||
if (shouldUnblock(paragraph)) {
|
||||
out.push(`<span>${paragraph.map((line) => line.trim()).join(" ")}</span>`);
|
||||
} else {
|
||||
out.push(...paragraph);
|
||||
}
|
||||
|
||||
if (end === index) {
|
||||
out.push(lines[end]);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
index = end;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function htmlPass(segments: Segment[]): Segment[] {
|
||||
return segments.map((segment) => {
|
||||
if (segment.fence) return segment;
|
||||
|
||||
const unblocked = unblockHtml(segment.lines);
|
||||
return {
|
||||
fence: false,
|
||||
language: "",
|
||||
lines: sanitizeInlineHtml(unblocked.join("\n")).split("\n"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function flatten(segments: Segment[]): string {
|
||||
const lines: string[] = [];
|
||||
for (const segment of segments) lines.push(...(segment.fence ? (segment.raw ?? []) : segment.lines));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Maths and markup only, for text a person wrote.
|
||||
*
|
||||
* A question goes through this rather than the full pass: a person pastes LaTeX
|
||||
* and writes HTML, but their typing is not the thing the code-fence repair
|
||||
* exists for, so a question is left as it was typed wherever the repair would
|
||||
* have changed it.
|
||||
*/
|
||||
export function renderInlineContent(markdown: string): string {
|
||||
return flatten(htmlPass(mathPass(splitSegments(markdown))));
|
||||
}
|
||||
|
||||
/**
|
||||
* The full rendering pass, from what the model wrote to what the pane draws.
|
||||
*
|
||||
* Takes one message's text, not a whole transcript: the transcript adds its own
|
||||
* markdown around each message (a blockquote for a question, a rule between
|
||||
* exchanges) and none of that should be read as model output.
|
||||
*/
|
||||
export function renderModelOutput(markdown: string): string {
|
||||
return renderInlineContent(repairCodeBlocks(markdown));
|
||||
}
|
||||
Reference in New Issue
Block a user