// 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 "
" 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
. 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", "
") }