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: "