// Package render turns raw repository bytes into HTML: syntax-highlighted code // via chroma and rendered Markdown via goldmark. It holds no git knowledge. package render import ( "bytes" "html/template" "path" "strings" "unicode/utf8" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" "github.com/yuin/goldmark" highlighting "github.com/yuin/goldmark-highlighting/v2" "github.com/yuin/goldmark/extension" "gopkg.in/yaml.v3" ) // FMPair is one ordered key/value from a Markdown file's YAML frontmatter. type FMPair struct { Key string Value string } // SplitFrontmatter separates a leading `---`-delimited YAML block from the body. // No frontmatter → fm is nil and body is the whole input. func SplitFrontmatter(raw []byte) (fm, body []byte) { s := string(raw) if !strings.HasPrefix(s, "---") { return nil, raw } nl := strings.IndexByte(s, '\n') if nl < 0 { return nil, raw } rest := s[nl+1:] end := strings.Index(rest, "\n---") if end < 0 { return nil, raw } fm = []byte(rest[:end]) after := rest[end+1:] // starts at closing "---" if nl2 := strings.IndexByte(after, '\n'); nl2 >= 0 { body = []byte(after[nl2+1:]) } return fm, body } // ParseFrontmatter parses a YAML frontmatter block into ordered key/value pairs // for display. Sequence values are comma-joined; nested maps are flattened to a // compact YAML string. Returns nil on empty or invalid input. func ParseFrontmatter(fm []byte) []FMPair { if len(bytes.TrimSpace(fm)) == 0 { return nil } var doc yaml.Node if err := yaml.Unmarshal(fm, &doc); err != nil || len(doc.Content) == 0 { return nil } root := doc.Content[0] if root.Kind != yaml.MappingNode { return nil } var pairs []FMPair for i := 0; i+1 < len(root.Content); i += 2 { pairs = append(pairs, FMPair{ Key: root.Content[i].Value, Value: nodeString(root.Content[i+1]), }) } return pairs } func nodeString(n *yaml.Node) string { switch n.Kind { case yaml.ScalarNode: return n.Value case yaml.SequenceNode: parts := make([]string, 0, len(n.Content)) for _, c := range n.Content { parts = append(parts, nodeString(c)) } return strings.Join(parts, ", ") default: var b bytes.Buffer enc := yaml.NewEncoder(&b) _ = enc.Encode(n) _ = enc.Close() return strings.TrimSpace(b.String()) } } // md renders GFM and highlights fenced code blocks with chroma classes, so // code in READMEs/issue bodies is colored by the same theme tokens as blobs. var md = goldmark.New(goldmark.WithExtensions( extension.GFM, highlighting.NewHighlighting( highlighting.WithFormatOptions(html.WithClasses(true)), ), )) // chroma formatters emit classes (not inline styles) so themes drive colors. // formatter is for source files (with a line-number gutter); diffFormatter omits // line numbers, which would fight a patch's own +/- columns. var formatter = html.New(html.WithClasses(true), html.WithLineNumbers(true), html.LineNumbersInTable(true)) var diffFormatter = html.New(html.WithClasses(true)) // classStyle is irrelevant to class-based output but required by the API. var classStyle = styles.Get("github") // Markdown renders GitHub-flavored Markdown to HTML. func Markdown(src []byte) (template.HTML, error) { var buf bytes.Buffer if err := md.Convert(src, &buf); err != nil { return "", err } return template.HTML(buf.String()), nil // #nosec G203 -- trusted repo content, rendered by goldmark } // Highlight returns syntax-highlighted HTML for code, choosing a lexer by // filename then content. Binary content yields ok=false so callers can show a // "binary file" notice instead. func Highlight(filename string, code []byte) (out template.HTML, ok bool) { if isBinary(code) { return "", false } lexer := lexers.Match(filename) if lexer == nil { lexer = lexers.Analyse(string(code)) } if lexer == nil { lexer = lexers.Fallback } lexer = chroma.Coalesce(lexer) iterator, err := lexer.Tokenise(nil, string(code)) if err != nil { return "", false } var buf bytes.Buffer if err := formatter.Format(&buf, classStyle, iterator); err != nil { return "", false } return template.HTML(buf.String()), true // #nosec G203 -- escaped by chroma formatter } // FileDiff is one file's slice of a commit diff: its name, highlighted hunks, // and add/delete line counts. type FileDiff struct { Name string HTML template.HTML Added int Deleted int Binary bool } // SplitDiff breaks a unified diff into per-file sections. Each section's git // metadata (diff --git / index / ---/+++ lines) is dropped — the filename is // surfaced separately — and the hunks are highlighted on their own. func SplitDiff(diff string) []FileDiff { if strings.TrimSpace(diff) == "" { return nil } lines := strings.Split(diff, "\n") var files []FileDiff var cur []string flush := func() { if len(cur) == 0 { return } files = append(files, buildFileDiff(cur)) cur = nil } for _, ln := range lines { if strings.HasPrefix(ln, "diff --git ") { flush() } cur = append(cur, ln) } flush() return files } func buildFileDiff(block []string) FileDiff { fd := FileDiff{Name: diffName(block)} hunk := -1 for i, ln := range block { switch { case strings.HasPrefix(ln, "@@") && hunk < 0: hunk = i case strings.HasPrefix(ln, "Binary files "), strings.HasPrefix(ln, "GIT binary patch"): fd.Binary = true case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"): fd.Added++ case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "---"): fd.Deleted++ } } if hunk >= 0 { fd.HTML = HighlightDiff(strings.Join(block[hunk:], "\n")) } return fd } // diffName extracts a display name from a file block's git header, formatting // renames as "old → new". func diffName(block []string) string { for _, ln := range block { if strings.HasPrefix(ln, "diff --git ") { fields := strings.Fields(ln) if len(fields) >= 4 { a := strings.TrimPrefix(fields[len(fields)-2], "a/") b := strings.TrimPrefix(fields[len(fields)-1], "b/") if a != b { return a + " → " + b } return b } } } for _, ln := range block { if strings.HasPrefix(ln, "+++ b/") { return strings.TrimPrefix(ln, "+++ b/") } } return "diff" } // HighlightDiff renders a unified diff with chroma's diff lexer (class-based: // .gi inserts, .gd deletes, .gu/.gh hunk headers). Falls back to escaped text. func HighlightDiff(diff string) template.HTML { lexer := lexers.Get("diff") if lexer == nil { return template.HTML("
" + template.HTMLEscapeString(diff) + "
") // #nosec G203 -- escaped } iterator, err := lexer.Tokenise(nil, diff) if err != nil { return template.HTML("
" + template.HTMLEscapeString(diff) + "
") // #nosec G203 -- escaped } var buf bytes.Buffer if err := diffFormatter.Format(&buf, classStyle, iterator); err != nil { return template.HTML("
" + template.HTMLEscapeString(diff) + "
") // #nosec G203 -- escaped } return template.HTML(buf.String()) // #nosec G203 -- escaped by chroma formatter } // IsMarkdown reports whether a filename should be rendered as Markdown. func IsMarkdown(name string) bool { switch strings.ToLower(path.Ext(name)) { case ".md", ".markdown", ".mdown": return true } return false } // isBinary uses a NUL-byte heuristic over the first chunk, like git. func isBinary(b []byte) bool { const sniff = 8000 if len(b) > sniff { b = b[:sniff] } if bytes.IndexByte(b, 0) >= 0 { return true } return !utf8.Valid(b) }