This commit is contained in:
359
pkg/renderkit/highlight.go
Normal file
359
pkg/renderkit/highlight.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
stdhtml "html"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// A small, dependency-free syntax highlighter for fenced code blocks.
|
||||
//
|
||||
// It is a single-pass lexer, not a parser: it recognises comments, strings,
|
||||
// numbers, keywords, built-in types, constants, function-call names and (for
|
||||
// shell) $variables, and wraps each in <span class="tok-...">. That is
|
||||
// enough to make code readable without pulling in a highlighting library or
|
||||
// a CDN (this stack must work fully offline).
|
||||
//
|
||||
// Safety: input is gomarkdown's already-escaped block body. It is unescaped,
|
||||
// tokenised, and every emitted piece is re-escaped, so no model-controlled
|
||||
// text can ever become markup. The visible text is unchanged (the copy
|
||||
// button still copies the original code).
|
||||
|
||||
// maxHighlightBytes skips highlighting for huge blocks; they are rare and
|
||||
// the lexer is per-byte.
|
||||
const maxHighlightBytes = 128 * 1024
|
||||
|
||||
type langSpec struct {
|
||||
keywords, types, consts map[string]bool
|
||||
lineComments []string
|
||||
blockStart, blockEnd string
|
||||
quotes string
|
||||
tripleQuotes bool
|
||||
foldCase bool // keywords are case-insensitive (SQL)
|
||||
shellVars bool
|
||||
jsonKeys bool
|
||||
}
|
||||
|
||||
func wordSet(words string) map[string]bool {
|
||||
m := make(map[string]bool)
|
||||
for _, w := range strings.Fields(words) {
|
||||
m[w] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
var (
|
||||
goSpec = &langSpec{
|
||||
keywords: wordSet(`break case chan const continue default defer else fallthrough for func go goto if
|
||||
import interface map package range return select struct switch type var`),
|
||||
types: wordSet(`string int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 uintptr byte rune
|
||||
float32 float64 complex64 complex128 bool error any`),
|
||||
consts: wordSet(`true false nil iota`),
|
||||
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'`",
|
||||
}
|
||||
jsSpec = &langSpec{
|
||||
keywords: wordSet(`async await break case catch class const continue debugger default delete do else
|
||||
export extends finally for from function if import in instanceof let new of return static super
|
||||
switch this throw try typeof var void while with yield interface type enum implements namespace
|
||||
declare readonly public private protected abstract as`),
|
||||
types: wordSet(`string number boolean any unknown never object Promise Array Map Set`),
|
||||
consts: wordSet(`true false null undefined NaN Infinity`),
|
||||
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'`",
|
||||
}
|
||||
pySpec = &langSpec{
|
||||
keywords: wordSet(`and as assert async await break class continue def del elif else except finally for
|
||||
from global if import in is lambda nonlocal not or pass raise return try while with yield match case`),
|
||||
types: wordSet(`int str float bool list dict set tuple bytes object`),
|
||||
consts: wordSet(`True False None`),
|
||||
lineComments: []string{"#"},
|
||||
quotes: "\"'", tripleQuotes: true,
|
||||
}
|
||||
shSpec = &langSpec{
|
||||
keywords: wordSet(`if then else elif fi for while until do done case esac in function select time
|
||||
return exit break continue export local readonly declare unset source alias`),
|
||||
types: wordSet(`echo cd ls cat grep sed awk find git go make docker curl mkdir rm cp mv chmod chown
|
||||
touch sudo npm pip python gofmt`),
|
||||
lineComments: []string{"#"},
|
||||
quotes: "\"'", shellVars: true,
|
||||
}
|
||||
jsonSpec = &langSpec{
|
||||
consts: wordSet(`true false null`),
|
||||
quotes: `"`, jsonKeys: true,
|
||||
}
|
||||
sqlSpec = &langSpec{
|
||||
keywords: wordSet(`select from where insert into values update set delete create table alter drop index
|
||||
join left right inner outer full cross on group by order having limit offset as and or not in is
|
||||
like between exists union all distinct case when then else end primary key foreign references
|
||||
default unique constraint with returning begin commit rollback extension if`),
|
||||
types: wordSet(`int integer bigint smallint serial bigserial text varchar char boolean bool date
|
||||
timestamp timestamptz numeric decimal float double real jsonb json uuid vector`),
|
||||
consts: wordSet(`true false null`),
|
||||
lineComments: []string{"--"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'", foldCase: true,
|
||||
}
|
||||
cSpec = &langSpec{
|
||||
keywords: wordSet(`auto break case const continue default do else enum extern for goto if inline
|
||||
register return sizeof static struct switch typedef union volatile while class namespace
|
||||
template typename public private protected virtual new delete using try catch throw`),
|
||||
types: wordSet(`void char short int long float double signed unsigned bool size_t int8_t int16_t
|
||||
int32_t int64_t uint8_t uint16_t uint32_t uint64_t string`),
|
||||
consts: wordSet(`true false NULL nullptr`),
|
||||
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'",
|
||||
}
|
||||
javaSpec = &langSpec{
|
||||
keywords: wordSet(`abstract assert break case catch class const continue default do else enum extends
|
||||
final finally for if implements import instanceof interface native new package private protected
|
||||
public return static super switch synchronized this throw throws try void volatile while`),
|
||||
types: wordSet(`boolean byte char short int long float double String Object List Map Set`),
|
||||
consts: wordSet(`true false null`), lineComments: []string{"//"},
|
||||
blockStart: "/*", blockEnd: "*/", quotes: "\"'",
|
||||
}
|
||||
// No ' in quotes: Rust lifetimes ('a) would swallow the rest of the line.
|
||||
rustSpec = &langSpec{
|
||||
keywords: wordSet(`as async await break const continue crate dyn else enum extern fn for if impl in
|
||||
let loop match mod move mut pub ref return self Self static struct super trait type unsafe use
|
||||
where while`),
|
||||
types: wordSet(`i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 bool char str String
|
||||
Vec Option Result Box`),
|
||||
consts: wordSet(`true false None Some Ok Err`), lineComments: []string{"//"},
|
||||
blockStart: "/*", blockEnd: "*/", quotes: `"`,
|
||||
}
|
||||
|
||||
langByName = map[string]*langSpec{
|
||||
"go": goSpec, "golang": goSpec,
|
||||
"js": jsSpec, "javascript": jsSpec, "jsx": jsSpec, "ts": jsSpec, "typescript": jsSpec, "tsx": jsSpec,
|
||||
"py": pySpec, "python": pySpec,
|
||||
"sh": shSpec, "bash": shSpec, "shell": shSpec, "zsh": shSpec, "console": shSpec,
|
||||
"json": jsonSpec,
|
||||
"sql": sqlSpec, "postgres": sqlSpec, "psql": sqlSpec,
|
||||
"c": cSpec, "cpp": cSpec, "c++": cSpec, "cc": cSpec, "h": cSpec, "hpp": cSpec,
|
||||
"java": javaSpec,
|
||||
"rust": rustSpec, "rs": rustSpec,
|
||||
}
|
||||
)
|
||||
|
||||
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||||
func isIdentStart(c byte) bool { return c == '_' || c == '$' || (c|0x20 >= 'a' && c|0x20 <= 'z') }
|
||||
func isIdent(c byte) bool { return isIdentStart(c) || isDigit(c) }
|
||||
|
||||
func spanned(b *strings.Builder, class, text string) {
|
||||
if class == "" {
|
||||
b.WriteString(stdhtml.EscapeString(text))
|
||||
return
|
||||
}
|
||||
b.WriteString(`<span class="tok-`)
|
||||
b.WriteString(class)
|
||||
b.WriteString(`">`)
|
||||
b.WriteString(stdhtml.EscapeString(text))
|
||||
b.WriteString(`</span>`)
|
||||
}
|
||||
|
||||
// highlight takes a fenced block's language and its HTML-escaped body and
|
||||
// returns the body with token spans. Unknown languages come back unchanged.
|
||||
func highlight(lang, escapedBody string) string {
|
||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||
if len(escapedBody) > maxHighlightBytes {
|
||||
return escapedBody
|
||||
}
|
||||
if lang == "diff" || lang == "patch" {
|
||||
return highlightDiff(stdhtml.UnescapeString(escapedBody))
|
||||
}
|
||||
spec := langByName[lang]
|
||||
if spec == nil {
|
||||
return escapedBody
|
||||
}
|
||||
return spec.lex(stdhtml.UnescapeString(escapedBody))
|
||||
}
|
||||
|
||||
func highlightDiff(src string) string {
|
||||
var b strings.Builder
|
||||
lines := strings.Split(src, "\n")
|
||||
for i, ln := range lines {
|
||||
class := ""
|
||||
switch {
|
||||
case strings.HasPrefix(ln, "+++") || strings.HasPrefix(ln, "---"):
|
||||
class = "hunk"
|
||||
case strings.HasPrefix(ln, "@@"):
|
||||
class = "hunk"
|
||||
case strings.HasPrefix(ln, "+"):
|
||||
class = "add"
|
||||
case strings.HasPrefix(ln, "-"):
|
||||
class = "del"
|
||||
}
|
||||
spanned(&b, class, ln)
|
||||
if i < len(lines)-1 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *langSpec) lex(src string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(src) + len(src)/2)
|
||||
i := 0
|
||||
for i < len(src) {
|
||||
c := src[i]
|
||||
rest := src[i:]
|
||||
|
||||
if n := s.commentLen(src, i); n > 0 {
|
||||
spanned(&b, "cmt", rest[:n])
|
||||
i += n
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.IndexByte(s.quotes, c) >= 0 {
|
||||
n := s.stringLen(rest)
|
||||
class := "str"
|
||||
if s.jsonKeys {
|
||||
k := i + n
|
||||
for k < len(src) && (src[k] == ' ' || src[k] == '\t') {
|
||||
k++
|
||||
}
|
||||
if k < len(src) && src[k] == ':' {
|
||||
class = "key"
|
||||
}
|
||||
}
|
||||
spanned(&b, class, rest[:n])
|
||||
i += n
|
||||
continue
|
||||
}
|
||||
|
||||
if isDigit(c) && (i == 0 || !isIdent(src[i-1])) {
|
||||
j := i + 1
|
||||
for j < len(src) && (isIdent(src[j]) || (src[j] == '.' && j+1 < len(src) && isDigit(src[j+1]))) {
|
||||
j++
|
||||
}
|
||||
spanned(&b, "num", src[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
if s.shellVars && c == '$' && i+1 < len(src) && (isIdentStart(src[i+1]) || src[i+1] == '{') {
|
||||
j := i + 1
|
||||
if src[j] == '{' {
|
||||
if k := strings.IndexAny(src[j:], "}\n"); k > 0 && src[j+k] == '}' {
|
||||
j += k + 1
|
||||
} else {
|
||||
j++
|
||||
}
|
||||
} else {
|
||||
for j < len(src) && isIdent(src[j]) {
|
||||
j++
|
||||
}
|
||||
}
|
||||
spanned(&b, "var", src[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
if isIdentStart(c) {
|
||||
j := i + 1
|
||||
for j < len(src) && isIdent(src[j]) {
|
||||
j++
|
||||
}
|
||||
spanned(&b, s.classify(src[i:j], src[j:]), src[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
_, size := utf8.DecodeRuneInString(rest)
|
||||
spanned(&b, "", rest[:size])
|
||||
i += size
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *langSpec) classify(word, after string) string {
|
||||
w := word
|
||||
if s.foldCase {
|
||||
w = strings.ToLower(word)
|
||||
}
|
||||
switch {
|
||||
case s.keywords[w]:
|
||||
return "kw"
|
||||
case s.types[w]:
|
||||
return "type"
|
||||
case s.consts[w]:
|
||||
return "const"
|
||||
}
|
||||
if len(s.types) > 0 || len(s.keywords) > 0 { // JSON has no functions
|
||||
for k := 0; k < len(after); k++ {
|
||||
if after[k] == ' ' || after[k] == '\t' {
|
||||
continue
|
||||
}
|
||||
if after[k] == '(' {
|
||||
return "fn"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commentLen returns the length of a comment starting at src[i], or 0.
|
||||
func (s *langSpec) commentLen(src string, i int) int {
|
||||
rest := src[i:]
|
||||
for _, lc := range s.lineComments {
|
||||
if !strings.HasPrefix(rest, lc) {
|
||||
continue
|
||||
}
|
||||
// A "#" only starts a comment at a word boundary, so "a#b" and
|
||||
// "$#" aren't swallowed.
|
||||
if lc == "#" && i > 0 && src[i-1] != ' ' && src[i-1] != '\t' && src[i-1] != '\n' && src[i-1] != ';' {
|
||||
continue
|
||||
}
|
||||
if k := strings.IndexByte(rest, '\n'); k >= 0 {
|
||||
return k
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
if s.blockStart != "" && strings.HasPrefix(rest, s.blockStart) {
|
||||
if k := strings.Index(rest[len(s.blockStart):], s.blockEnd); k >= 0 {
|
||||
return len(s.blockStart) + k + len(s.blockEnd)
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// stringLen returns the length of the string literal starting at rest[0].
|
||||
// Unterminated ' and " literals end at the newline, so one stray quote
|
||||
// can't colour the rest of the block.
|
||||
func (s *langSpec) stringLen(rest string) int {
|
||||
q := rest[0]
|
||||
if s.tripleQuotes {
|
||||
if t := strings.Repeat(string(q), 3); strings.HasPrefix(rest, t) {
|
||||
if k := strings.Index(rest[3:], t); k >= 0 {
|
||||
return 3 + k + 3
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
}
|
||||
if q == '`' { // raw / multi-line
|
||||
if k := strings.IndexByte(rest[1:], '`'); k >= 0 {
|
||||
return 1 + k + 1
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
j := 1
|
||||
for j < len(rest) {
|
||||
switch rest[j] {
|
||||
case '\\':
|
||||
j += 2
|
||||
continue
|
||||
case q:
|
||||
return j + 1
|
||||
case '\n':
|
||||
return j
|
||||
}
|
||||
j++
|
||||
}
|
||||
if j > len(rest) {
|
||||
j = len(rest)
|
||||
}
|
||||
return j
|
||||
}
|
||||
81
pkg/renderkit/highlight_test.go
Normal file
81
pkg/renderkit/highlight_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
stdhtml "html"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// visibleText strips the span markup and unescapes, i.e. what the user
|
||||
// sees and what the copy button copies.
|
||||
func visibleText(s string) string { return stdhtml.UnescapeString(tagRe.ReplaceAllString(s, "")) }
|
||||
|
||||
func TestHighlightPreservesText(t *testing.T) {
|
||||
samples := map[string]string{
|
||||
"go": "package main\n\n// hi\nfunc main() {\n\tx := \"a<b>\" + `raw\nline`\n\tif x != nil { return 42 }\n}\n",
|
||||
"py": "def f(x):\n \"\"\"doc\"\"\"\n return x # c\n",
|
||||
"bash": "echo \"$HOME\" ${A} | grep 'x' # note\nfoo#bar $#\n",
|
||||
"json": "{\"a\": [1, 2.5, true, null], \"b\": \"x\"}",
|
||||
"sql": "SELECT count(*) FROM t WHERE a = 'x' -- c\n",
|
||||
"rust": "fn f<'a>(x: &'a str) -> u32 { 1 }\n",
|
||||
"diff": "@@ -1 +1 @@\n-old\n+new\n ctx\n",
|
||||
}
|
||||
for lang, src := range samples {
|
||||
out := highlight(lang, stdhtml.EscapeString(src))
|
||||
if got := visibleText(out); got != src {
|
||||
t.Errorf("%s: text changed\nwant %q\ngot %q", lang, src, got)
|
||||
}
|
||||
if !strings.Contains(out, "<span class=\"tok-") {
|
||||
t.Errorf("%s: nothing was highlighted: %s", lang, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightTokens(t *testing.T) {
|
||||
out := highlight("go", stdhtml.EscapeString("func main() { return \"a<b>\" } // done"))
|
||||
for _, want := range []string{
|
||||
`<span class="tok-kw">func</span>`,
|
||||
`<span class="tok-fn">main</span>`,
|
||||
`<span class="tok-kw">return</span>`,
|
||||
`<span class="tok-str">"a<b>"</span>`,
|
||||
`<span class="tok-cmt">// done</span>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %s in %s", want, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(highlight("json", `"k": 1`), `tok-key`) {
|
||||
t.Error("json key not detected")
|
||||
}
|
||||
if !strings.Contains(highlight("sql", "select 1"), `<span class="tok-kw">select</span>`) {
|
||||
t.Error("sql keywords should be case-insensitive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightNeverInjectsMarkup(t *testing.T) {
|
||||
evil := "x := \"</span><script>alert(1)</script>\" // <img src=x onerror=1>"
|
||||
out := highlight("go", stdhtml.EscapeString(evil))
|
||||
if strings.Contains(out, "<script") || strings.Contains(out, "<img") {
|
||||
t.Fatalf("markup leaked: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightUnknownLanguageUnchanged(t *testing.T) {
|
||||
in := "a < b"
|
||||
if got := highlight("brainfuck", in); got != in {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := highlight("", in); got != in {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownHighlightsFencedCode(t *testing.T) {
|
||||
out := Markdown("```go\nfunc main() {}\n```")
|
||||
if !strings.Contains(out, `<span class="tok-kw">func</span>`) || !strings.Contains(out, "copy-btn") {
|
||||
t.Fatalf("not highlighted: %s", out)
|
||||
}
|
||||
}
|
||||
94
pkg/renderkit/markdown.go
Normal file
94
pkg/renderkit/markdown.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// 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>")
|
||||
}
|
||||
49
pkg/renderkit/markdown_test.go
Normal file
49
pkg/renderkit/markdown_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownDropsRawHTMLAndUnsafeLinks(t *testing.T) {
|
||||
out := Markdown("hi <script>alert(1)</script> <img src=x onerror=alert(1)> [x](javascript:alert(1))\n\n<div onclick=\"x()\">boom</div>")
|
||||
for _, bad := range []string{"<script", "<img", "onerror", "onclick", `href="javascript:`} {
|
||||
if strings.Contains(out, bad) {
|
||||
t.Fatalf("output contains %q:\n%s", bad, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownKeepsCodeAndFormatting(t *testing.T) {
|
||||
out := Markdown("**bold**\n\n```go\nx := \"<div>\" + `a`\n```\n\n`<b>`")
|
||||
for _, want := range []string{"<strong>bold</strong>", "code-block", "copy-btn", "<div>", "<b>"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainStreamEscapes(t *testing.T) {
|
||||
if got := PlainStream("<b>a</b>\nb"); got != "<b>a</b><br>b" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: gomarkdown's Parser panics if reused, which used to make every
|
||||
// reply after the first fail. Also exercised concurrently for the race detector.
|
||||
func TestMarkdownIsReusableAndConcurrencySafe(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < 8; i++ {
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
for j := 0; j < 20; j++ {
|
||||
if out := Markdown("# title\n\ntext"); !strings.Contains(out, "<h1") {
|
||||
t.Error(out)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
76
pkg/renderkit/snippets.go
Normal file
76
pkg/renderkit/snippets.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
stdhtml "html"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Snippet describes one fenced code block in an assistant reply that is
|
||||
// worth offering as a download.
|
||||
type Snippet struct {
|
||||
Index int // position among ALL fenced blocks in the reply (matches the DOM order of .code-block)
|
||||
Lang string // lower-cased fence language
|
||||
Ext string // file extension including the dot, e.g. ".go"
|
||||
Label string // display name of the type, e.g. "Go"
|
||||
Lines int
|
||||
Bytes int
|
||||
// Filename is set when the block's first line is a comment naming a
|
||||
// file with the matching extension (e.g. "// internal/db/users.go"),
|
||||
// which models often write; only the base name is kept.
|
||||
Filename string
|
||||
}
|
||||
|
||||
var firstLineFilename = regexp.MustCompile(`^\s*(?://|#|--|/\*|<!--)\s*([\w./-]+\.\w+)\s*(?:\*/|-->)?\s*$`)
|
||||
|
||||
func filenameHint(body, ext string) string {
|
||||
first, _, _ := strings.Cut(body, "\n")
|
||||
m := firstLineFilename.FindStringSubmatch(first)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
base := path.Base(m[1])
|
||||
if path.Ext(base) != ext || base == ext {
|
||||
return ""
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
type snippetType struct{ ext, label string }
|
||||
|
||||
var snippetTypes = map[string]snippetType{
|
||||
"go": {".go", "Go"}, "golang": {".go", "Go"},
|
||||
"js": {".js", "JavaScript"}, "javascript": {".js", "JavaScript"},
|
||||
"jsx": {".jsx", "JSX"}, "ts": {".ts", "TypeScript"}, "typescript": {".ts", "TypeScript"}, "tsx": {".tsx", "TSX"},
|
||||
"py": {".py", "Python"}, "python": {".py", "Python"},
|
||||
"sh": {".sh", "Shell"}, "bash": {".sh", "Shell"}, "shell": {".sh", "Shell"}, "zsh": {".sh", "Shell"},
|
||||
"json": {".json", "JSON"}, "sql": {".sql", "SQL"},
|
||||
"c": {".c", "C"}, "h": {".h", "C header"}, "cpp": {".cpp", "C++"}, "c++": {".cpp", "C++"},
|
||||
"java": {".java", "Java"}, "rust": {".rs", "Rust"}, "rs": {".rs", "Rust"},
|
||||
"diff": {".patch", "Patch"}, "patch": {".patch", "Patch"},
|
||||
"yaml": {".yml", "YAML"}, "yml": {".yml", "YAML"}, "toml": {".toml", "TOML"},
|
||||
"html": {".html", "HTML"}, "css": {".css", "CSS"},
|
||||
"md": {".md", "Markdown"}, "markdown": {".md", "Markdown"},
|
||||
}
|
||||
|
||||
// Snippets lists the fenced code blocks in src that have a recognised
|
||||
// language, in the order Markdown renders them. Blocks with no language (or
|
||||
// an unknown one) are skipped, but still counted in Index.
|
||||
func Snippets(src string) []Snippet {
|
||||
var out []Snippet
|
||||
for i, m := range fencedCodeBlock.FindAllStringSubmatch(rawMarkdown(src), -1) {
|
||||
lang := strings.ToLower(m[1])
|
||||
t, ok := snippetTypes[lang]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
body := stdhtml.UnescapeString(m[2])
|
||||
out = append(out, Snippet{
|
||||
Index: i, Lang: lang, Ext: t.ext, Label: t.label,
|
||||
Lines: strings.Count(strings.TrimRight(body, "\n"), "\n") + 1,
|
||||
Bytes: len(body), Filename: filenameHint(body, t.ext),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
34
pkg/renderkit/snippets_test.go
Normal file
34
pkg/renderkit/snippets_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package renderkit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSnippets(t *testing.T) {
|
||||
src := "intro\n\n```\nno lang\n```\n\n```go\npackage main\n\nfunc main() {}\n```\n\n```python\nprint(1)\n```\n\n```weird\nx\n```\n"
|
||||
got := Snippets(src)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 snippets, got %+v", got)
|
||||
}
|
||||
if got[0].Index != 1 || got[0].Ext != ".go" || got[0].Lines != 3 {
|
||||
t.Errorf("go snippet wrong: %+v", got[0])
|
||||
}
|
||||
if got[1].Index != 2 || got[1].Ext != ".py" || got[1].Label != "Python" {
|
||||
t.Errorf("python snippet wrong: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetFilenameHint(t *testing.T) {
|
||||
src := "```go\n// internal/db/users.go\npackage db\n\nvar X = 1\n```\n\n```go\n// just a comment\npackage x\n\nvar Y = 2\n```\n\n```python\n# notes.txt\nprint(1)\nprint(2)\n```\n"
|
||||
got := Snippets(src)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3, got %+v", got)
|
||||
}
|
||||
if got[0].Filename != "users.go" {
|
||||
t.Errorf("want users.go, got %q", got[0].Filename)
|
||||
}
|
||||
if got[1].Filename != "" {
|
||||
t.Errorf("plain comment must not become a filename: %q", got[1].Filename)
|
||||
}
|
||||
if got[2].Filename != "" {
|
||||
t.Errorf("extension mismatch (.txt vs .py) must be ignored: %q", got[2].Filename)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user