Files
llmgw/internal/web/modelcard.go
Ilker ManapandClaude Opus 5.5 6b1f8b0827 Render a finished chat reply as markdown
A reply streams as text as before; once it ends, the server renders it
with the model card's rules - raw HTML dropped, dangerous links emptied,
images as links - and the page places that. Headings, lists, tables and
code blocks read as they were written.

Maths outside code is set aside before the markdown, so its underscores
and asterisks are not taken for emphasis, and marked for the page to
typeset. KaTeX no longer searches the reply for delimiters, which took
"$5 and $10" for a formula. A reply that cannot be rendered stays
plain text, with the old search.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:44:18 +02:00

121 lines
3.7 KiB
Go

package web
import (
"bytes"
"html/template"
"net/url"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// renderCard turns a model card into HTML for the mirror page.
//
// The card is a stranger's text, so what it may do is narrow:
// - raw HTML is dropped, which goldmark does unless told otherwise, and
// links to javascript: and the like are emptied, which it also does;
// - images are not loaded but shown as links, because an admin page that
// fetches whatever a README points at tells a stranger who is looking;
// - relative links, to the repository's own LICENSE and files, are made to
// point at the repository rather than at this page.
func renderCard(card, repoBase string) template.HTML {
return safeMarkdown(card, repoBase)
}
// safeMarkdown renders a stranger's markdown under the rules above. base, when
// set, is what relative links resolve against.
func safeMarkdown(src, base string) template.HTML {
if strings.TrimSpace(src) == "" {
return ""
}
md := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(parser.WithASTTransformers(
util.Prioritized(cardLinks{base: base}, 100))),
goldmark.WithRendererOptions(
renderer.WithNodeRenderers(util.Prioritized(imageAsLink{}, 100))),
)
var out bytes.Buffer
if err := md.Convert([]byte(src), &out); err != nil {
return ""
}
// Safe by construction: goldmark in its default, safe mode, with the two
// changes above. Not something a template may escape again.
return template.HTML(out.String()) //nolint:gosec
}
// cardLinks resolves a card's relative links against its repository.
type cardLinks struct{ base string }
func (c cardLinks) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) {
base, err := url.Parse(c.base)
if err != nil || c.base == "" {
return
}
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
var dest *[]byte
switch l := n.(type) {
case *ast.Link:
dest = &l.Destination
case *ast.Image:
dest = &l.Destination
default:
return ast.WalkContinue, nil
}
u, err := url.Parse(string(*dest))
if err != nil || u.IsAbs() || strings.HasPrefix(string(*dest), "#") {
return ast.WalkContinue, nil
}
*dest = []byte(base.ResolveReference(u).String())
return ast.WalkContinue, nil
})
}
// imageAsLink renders an image as a link to it, named by its alt text.
type imageAsLink struct{}
func (imageAsLink) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindImage, func(w util.BufWriter, src []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
img := n.(*ast.Image)
alt := strings.TrimSpace(string(nodeText(img, src)))
if alt == "" {
alt = "image"
}
dest := img.Destination
if html.IsDangerousURL(dest) {
_, _ = w.WriteString(`<span class="img">[` + template.HTMLEscapeString(alt) + `]</span>`)
return ast.WalkSkipChildren, nil
}
_, _ = w.WriteString(`<a class="img" rel="noopener noreferrer" href="` +
template.HTMLEscapeString(string(util.URLEscape(dest, true))) + `">[` +
template.HTMLEscapeString(alt) + `]</a>`)
return ast.WalkSkipChildren, nil
})
}
// nodeText is the plain text beneath a node.
func nodeText(n ast.Node, src []byte) []byte {
var b bytes.Buffer
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
if t, ok := c.(*ast.Text); ok {
b.Write(t.Segment.Value(src))
continue
}
b.Write(nodeText(c, src))
}
return b.Bytes()
}