Files
enkly-ai/pkg/renderkit/markdown.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

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