// Package render turns raw assistant text into safe HTML: Markdown for
// finished messages, and simple HTML-escaping (with
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:
//
...escaped content...
// (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)(.*?)`)
// 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 ". 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
// , 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 `` +
`` +
`` + stdhtml.EscapeString(label) + `` +
`` +
`` +
`` + highlight(lang, body) + `
` +
``
})
}
// PlainStream escapes raw text and converts newlines to
, 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", "
")
}