diff --git a/go.mod b/go.mod
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,7 @@
github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab
github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654
github.com/bmatcuk/doublestar/v4 v4.9.1
+ github.com/buildkite/terminal-to-html/v3 v3.16.8
github.com/carlmjohnson/versioninfo v0.22.5
github.com/casbin/casbin/v2 v2.103.0
github.com/charmbracelet/bubbles v1.0.0
diff --git a/go.sum b/go.sum
--- a/go.sum
+++ b/go.sum
@@ -124,6 +124,8 @@
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/buildkite/terminal-to-html/v3 v3.16.8 h1:QN/daUob6cmK8GcdKnwn9+YTlPr1vNj+oeAIiJK6fPc=
+github.com/buildkite/terminal-to-html/v3 v3.16.8/go.mod h1:+k1KVKROZocrTLsEQ9PEf9A+8+X8uaVV5iO1ZIOwKYM=
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
github.com/casbin/casbin/v2 v2.100.0/go.mod h1:LO7YPez4dX3LgoTCqSQAleQDo0S0BeZBDxYnPUl95Ng=
diff --git a/input.css b/input.css
--- a/input.css
+++ b/input.css
@@ -1442,4 +1442,62 @@
--hit-area-t: -3rem;
--hit-area-b: -3rem;
}
+
+ /* terminal-to-html: catpuccin colors */
+ .term-fg30 { color: #6c6f85; } /* black */
+ .term-fg31 { color: #d20f39; } /* red */
+ .term-fg32 { color: #40a02b; } /* green */
+ .term-fg33 { color: #df8e1d; } /* yellow */
+ .term-fg34 { color: #1e66f5; } /* blue */
+ .term-fg35 { color: #8839ef; } /* magenta */
+ .term-fg36 { color: #179299; } /* cyan */
+ .term-fg37 { color: #4c4f69; } /* white */
+ .term-fgi90 { color: #8c8fa1; } /* bright-black */
+ .term-fgi91 { color: #e64553; } /* bright-red */
+ .term-fgi92 { color: #40a02b; } /* bright-green */
+ .term-fgi93 { color: #e5c890; } /* bright-yellow */
+ .term-fgi94 { color: #209fb5; } /* bright-blue */
+ .term-fgi95 { color: #ea76cb; } /* bright-magenta */
+ .term-fgi96 { color: #04a5e5; } /* bright-cyan */
+ .term-fgi97 { color: #bcc0cc; } /* bright-white */
+ .term-bg40 { background-color: #6c6f85; } /* black */
+ .term-bg41 { background-color: #d20f39; } /* red */
+ .term-bg42 { background-color: #40a02b; } /* green */
+ .term-bg43 { background-color: #df8e1d; } /* yellow */
+ .term-bg44 { background-color: #1e66f5; } /* blue */
+ .term-bg45 { background-color: #8839ef; } /* magenta */
+ .term-bg46 { background-color: #179299; } /* cyan */
+ .term-bg47 { background-color: #dce0e8; } /* white */
+ .term-fg1 { @apply font-bold; }
+ .term-fg2 { @apply opacity-60; }
+ .term-fg3 { @apply italic; }
+ .term-fg4 { @apply underline; }
+ .term-fg9 { @apply line-through; }
+
+ @media (prefers-color-scheme: dark) {
+ .term-fg30 { color: #a5adcb; } /* black */
+ .term-fg31 { color: #ed8796; } /* red */
+ .term-fg32 { color: #a6da95; } /* green */
+ .term-fg33 { color: #eed49f; } /* yellow */
+ .term-fg34 { color: #8aadf4; } /* blue */
+ .term-fg35 { color: #c6a0f6; } /* magenta */
+ .term-fg36 { color: #8bd5ca; } /* cyan */
+ .term-fg37 { color: #cad3f5; } /* white */
+ .term-fgi90 { color: #8087a2; } /* bright-black */
+ .term-fgi91 { color: #f5bde6; } /* bright-red */
+ .term-fgi92 { color: #a6da95; } /* bright-green */
+ .term-fgi93 { color: #f5a97f; } /* bright-yellow */
+ .term-fgi94 { color: #7dc4e4; } /* bright-blue */
+ .term-fgi95 { color: #f4dbd6; } /* bright-magenta */
+ .term-fgi96 { color: #91d7e3; } /* bright-cyan */
+ .term-fgi97 { color: #f0c6c6; } /* bright-white */
+ .term-bg40 { background-color: #a5adcb; } /* black */
+ .term-bg41 { background-color: #ed8796; } /* red */
+ .term-bg42 { background-color: #a6da95; } /* green */
+ .term-bg43 { background-color: #eed49f; } /* yellow */
+ .term-bg44 { background-color: #8aadf4; } /* blue */
+ .term-bg45 { background-color: #c6a0f6; } /* magenta */
+ .term-bg46 { background-color: #8bd5ca; } /* cyan */
+ .term-bg47 { background-color: #494d64; } /* white */
+ }
}
diff --git a/tailwind.config.js b/tailwind.config.js
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -2,6 +2,9 @@
const colors = require("tailwindcss/colors");
module.exports = {
+ safelist: [
+ { pattern: /^term-/ },
+ ],
content: [
"./appview/pages/templates/**/*.html",
"./appview/pages/chroma.go",
diff --git a/appview/pages/pages.go b/appview/pages/pages.go
--- a/appview/pages/pages.go
+++ b/appview/pages/pages.go
@@ -1536,7 +1536,7 @@
type LogLineParams struct {
Id int
- Content string
+ Content template.HTML
}
func (p *Pages) LogLine(w io.Writer, params LogLineParams) error {
diff --git a/appview/pipelines/ansi_test.go b/appview/pipelines/ansi_test.go
new file mode 100644
--- /dev/null
+++ b/appview/pipelines/ansi_test.go
@@ -0,0 +1,144 @@
+package pipelines
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestAnsiState_SingleLine(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantContain string // substring expected in rendered HTML
+ }{
+ {"bold", "\033[1mBold\033[0m", "term-fg1"},
+ {"red", "\033[31mRed\033[0m", "term-fg31"},
+ {"green", "\033[32mGreen\033[0m", "term-fg32"},
+ {"yellow", "\033[33mYellow\033[0m", "term-fg33"},
+ {"blue", "\033[34mBlue\033[0m", "term-fg34"},
+ {"magenta", "\033[35mMagenta\033[0m", "term-fg35"},
+ {"cyan", "\033[36mCyan\033[0m", "term-fg36"},
+ {"bold green", "\033[1;32mBold Green\033[0m", "term-fg32"},
+ {"red background", "\033[41m Red background \033[0m", "term-bg41"},
+ {"256 orange", "\033[38;5;208mANSI 256 orange\033[0m", "term-fgx208"},
+ // true color is stripped
+ {"true color", "\033[38;2;255;100;0mTrue color orange\033[0m", "True color orange"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ a := NewAnsiState()
+ got := string(a.Render(tt.input))
+ t.Logf("%s → %s", tt.name, got)
+ if !strings.Contains(got, tt.wantContain) {
+ t.Errorf("expected output to contain %q, got: %s", tt.wantContain, got)
+ }
+ })
+ }
+}
+
+func TestAnsiState_MultiLine_CarryOver(t *testing.T) {
+ // red opened on line 1 without reset — line 2 should carry it over.
+ a := NewAnsiState()
+
+ line1 := a.Render("\033[31mstart of red")
+ if !strings.Contains(string(line1), "term-fg31") {
+ t.Errorf("line1: expected term-fg31, got: %s", line1)
+ }
+
+ line2 := a.Render("still red\033[0m")
+ t.Logf("line2 → %s", line2)
+ if !strings.Contains(string(line2), "term-fg31") {
+ t.Errorf("line2: expected carry-over term-fg31, got: %s", line2)
+ }
+
+ // after the reset, line 3 should have no colour.
+ line3 := a.Render("plain text")
+ t.Logf("line3 → %s", line3)
+ if strings.Contains(string(line3), "term-fg31") {
+ t.Errorf("line3: expected no term-fg31 after reset, got: %s", line3)
+ }
+}
+
+func TestAnsiState_MultiLine_StackedSequences(t *testing.T) {
+ // bold opened line 1, red added line 2 — both should carry to line 2.
+ a := NewAnsiState()
+
+ a.Render("\033[1mBold opened")
+
+ line2 := a.Render("\033[31mRed added")
+ t.Logf("line2 → %s", line2)
+ if !strings.Contains(string(line2), "term-fg1") {
+ t.Errorf("line2: expected carried-over term-fg1, got: %s", line2)
+ }
+ if !strings.Contains(string(line2), "term-fg31") {
+ t.Errorf("line2: expected term-fg31, got: %s", line2)
+ }
+
+ a.Render("\033[0mReset")
+
+ line4 := a.Render("plain")
+ t.Logf("line4 → %s", line4)
+ if strings.Contains(string(line4), "term-") {
+ t.Errorf("line4: expected no term- classes after reset, got: %s", line4)
+ }
+}
+
+func TestAnsiState_Reset_ClearsStack(t *testing.T) {
+ a := NewAnsiState()
+ a.Render("\033[31m\033[1m\033[33mMultiple opens")
+ if len(a.stack) != 3 {
+ t.Errorf("expected stack depth 3, got %d", len(a.stack))
+ }
+ a.Render("\033[0mReset line")
+ if len(a.stack) != 0 {
+ t.Errorf("expected empty stack after reset, got %d: %v", len(a.stack), a.stack)
+ }
+}
+
+func TestAnsiState_NoAnsi(t *testing.T) {
+ a := NewAnsiState()
+ got := string(a.Render("plain text no escapes"))
+ t.Logf("got → %s", got)
+ if strings.Contains(got, "term-") {
+ t.Errorf("expected no term- classes for plain text, got: %s", got)
+ }
+}
+
+func TestAnsiState_Sanitizer_XSS(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ shuoldBeAbsent string // must not appear literally (unescaped) in output
+ }{
+ {
+ name: "script tag not executable",
+ input: "",
+ shuoldBeAbsent: "\033[0m",
+ shuoldBeAbsent: "