95 lines
4.2 KiB
Go
95 lines
4.2 KiB
Go
// Package render turns raw assistant text into safe HTML: Markdown for
|
|
// finished messages, and simple HTML-escaping (with <br> line breaks)
|
|
// for the plain-text tokens shown while a reply is still streaming.
|
|
package renderkit
|
|
|
|
import (
|
|
stdhtml "html"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/gomarkdown/markdown"
|
|
mdhtml "github.com/gomarkdown/markdown/html"
|
|
mdparser "github.com/gomarkdown/markdown/parser"
|
|
)
|
|
|
|
// gomarkdown's Parser and Renderer are single-use and stateful ("Parser is
|
|
// not reusable" — a second Parse on the same instance panics, and sharing
|
|
// one across goroutines would race), so both are built fresh per call.
|
|
const (
|
|
mdParserExtensions = mdparser.CommonExtensions | mdparser.AutoHeadingIDs | mdparser.FencedCode
|
|
mdRendererFlags = mdhtml.CommonFlags | mdhtml.HrefTargetBlank | mdhtml.SkipHTML | mdhtml.Safelink
|
|
)
|
|
|
|
// fencedCodeBlock matches gomarkdown's output for a fenced code block:
|
|
// <pre><code class="language-xxx">...escaped content...</code></pre>
|
|
// (the language class is only present when the fence specified one,
|
|
// e.g. ```go). (?s) lets "." match newlines, since code blocks are
|
|
// almost always multi-line.
|
|
var fencedCodeBlock = regexp.MustCompile(`(?s)<pre><code(?: class="language-([\w+.-]*)")?>(.*?)</code></pre>`)
|
|
|
|
// Markdown renders a complete Markdown document (the agent's finished
|
|
// reply) to HTML for embedding in a chat bubble.
|
|
//
|
|
// The text being rendered is model output, and the model reads files
|
|
// and command output that may contain attacker-controlled text (prompt
|
|
// injection), so it is treated as untrusted: raw HTML in the Markdown
|
|
// source is dropped (SkipHTML) and only safe link schemes are linked
|
|
// (Safelink — no javascript: URLs). Code spans and fenced blocks are
|
|
// still HTML-escaped by gomarkdown, so showing "<div>" in code works.
|
|
//
|
|
// Fenced code blocks get an extra post-processing pass (addCodeBlockChrome)
|
|
// to wrap them in a header bar with a language label and copy button,
|
|
// matching the rest of the UI's terminal-chat styling — done as plain
|
|
// string manipulation on gomarkdown's already-escaped output rather
|
|
// than pulling in a syntax-highlighting dependency (see the module's
|
|
// deliberately small go.mod). Keywords, strings, comments, numbers and so
|
|
// on are coloured by the small built-in lexer in highlight.go.
|
|
func Markdown(src string) string {
|
|
return addCodeBlockChrome(rawMarkdown(src))
|
|
}
|
|
|
|
// rawMarkdown is gomarkdown's output before addCodeBlockChrome. Split out so
|
|
// Snippets (snippets.go) can enumerate fenced blocks in exactly the same
|
|
// order Markdown renders them.
|
|
func rawMarkdown(src string) string {
|
|
p := mdparser.NewWithExtensions(mdParserExtensions)
|
|
r := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdRendererFlags})
|
|
return string(markdown.ToHTML([]byte(src), p, r))
|
|
}
|
|
|
|
// addCodeBlockChrome wraps every fenced code block in a `.code-block`
|
|
// container with a header (language label + copy button) so it reads
|
|
// like a small file viewer instead of a bare <pre>. The copy button has
|
|
// no inline JS handler — web/static/js/app.js listens for clicks on
|
|
// `.copy-btn` via event delegation and reads the code from a sibling
|
|
// <pre>, so this still degrades gracefully (the button just does
|
|
// nothing) if that script is blocked, matching the rest of the app's
|
|
// zero-required-JS posture.
|
|
func addCodeBlockChrome(html string) string {
|
|
return fencedCodeBlock.ReplaceAllStringFunc(html, func(block string) string {
|
|
m := fencedCodeBlock.FindStringSubmatch(block)
|
|
lang := m[1]
|
|
body := m[2]
|
|
label := lang
|
|
if label == "" {
|
|
label = "text"
|
|
}
|
|
return `<div class="code-block">` +
|
|
`<div class="code-block-header">` +
|
|
`<span class="code-block-lang">` + stdhtml.EscapeString(label) + `</span>` +
|
|
`<button type="button" class="copy-btn" aria-label="Copy code">Copy</button>` +
|
|
`</div>` +
|
|
`<pre><code class="language-` + stdhtml.EscapeString(label) + `">` + highlight(lang, body) + `</code></pre>` +
|
|
`</div>`
|
|
})
|
|
}
|
|
|
|
// PlainStream escapes raw text and converts newlines to <br>, for
|
|
// rendering the in-progress streamed response before the final Markdown
|
|
// pass runs on the "done" event.
|
|
func PlainStream(src string) string {
|
|
escaped := stdhtml.EscapeString(src)
|
|
return strings.ReplaceAll(escaped, "\n", "<br>")
|
|
}
|