gitmote

gitmote / internal / render / ansi.go  ·  history  ·  branches

2649 bytes · raw

package render

import (
	"html/template"
	"strconv"
	"strings"
)

// ansiFG maps SGR foreground color codes (standard 30–37, bright 90–97) to CSS
// colors. Backgrounds and 256/truecolor are intentionally unsupported — CI logs
// use the basic palette, and a fixed table keeps the output injection-proof (the
// color never comes from the log).
var ansiFG = map[int]string{
	30: "#555555", 31: "#c0392b", 32: "#27ae60", 33: "#b7950b",
	34: "#2980b9", 35: "#8e44ad", 36: "#17a2a2", 37: "#aaaaaa",
	90: "#7f8c8d", 91: "#e74c3c", 92: "#2ecc71", 93: "#f1c40f",
	94: "#3498db", 95: "#9b59b6", 96: "#1abc9c", 97: "#ecf0f1",
}

// ANSI converts a byte stream carrying ANSI SGR escapes into HTML safe to embed
// in a <pre>. All text is HTML-escaped; SGR color/bold escapes become <span>
// wrappers with styles drawn only from the fixed palette; every other escape
// sequence (cursor moves, etc.) is dropped. Because the styles are ours and the
// text is escaped, the untrusted log can't inject markup.
func ANSI(b []byte) template.HTML {
	var out strings.Builder
	color := ""
	bold := false

	flush := func(s []byte) {
		if len(s) == 0 {
			return
		}
		esc := template.HTMLEscapeString(string(s))
		if color == "" && !bold {
			out.WriteString(esc)
			return
		}
		out.WriteString(`<span style="`)
		if bold {
			out.WriteString("font-weight:bold;")
		}
		if color != "" {
			out.WriteString("color:")
			out.WriteString(color)
			out.WriteString(";")
		}
		out.WriteString(`">`)
		out.WriteString(esc)
		out.WriteString("</span>")
	}

	i, n, start := 0, len(b), 0
	for i < n {
		if b[i] != 0x1b { // ESC
			i++
			continue
		}
		flush(b[start:i])
		if i+1 < n && b[i+1] == '[' { // CSI: ESC [ params <final>
			j := i + 2
			for j < n && (b[j] < 0x40 || b[j] > 0x7e) {
				j++
			}
			if j >= n { // unterminated — drop the rest
				i = n
			} else {
				if b[j] == 'm' {
					applySGR(string(b[i+2:j]), &color, &bold)
				}
				i = j + 1
			}
		} else {
			i++ // lone ESC — skip it
		}
		start = i
	}
	flush(b[start:n])
	return template.HTML(out.String())
}

// applySGR updates the current color/bold from a semicolon-separated SGR
// parameter list. Unknown codes are ignored.
func applySGR(params string, color *string, bold *bool) {
	if params == "" { // ESC[m is a reset
		*color, *bold = "", false
		return
	}
	for _, p := range strings.Split(params, ";") {
		code, err := strconv.Atoi(p)
		if err != nil {
			continue
		}
		switch code {
		case 0:
			*color, *bold = "", false
		case 1:
			*bold = true
		case 22:
			*bold = false
		case 39:
			*color = ""
		default:
			if c, ok := ansiFG[code]; ok {
				*color = c
			}
		}
	}
}