Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

appview/pipelines: render ANSI sequences

uses the terminal-to-html to convert lines with sequences into
renderable html.

Signed-off-by: oppiliappan <me@oppi.li>

author
oppiliappan
committer
Tangled
date (May 26, 2026, 9:08 AM +0300) commit c3f92214 parent 2cd46e69 change-id zxzxzuzr
+281 -3
+21
appview/pages/markup/sanitizer.go
··· 14 14 var ( 15 15 sharedDefaultPolicy *bluemonday.Policy 16 16 sharedDescriptionPolicy *bluemonday.Policy 17 + sharedLogsPolicy *bluemonday.Policy 17 18 ) 18 19 19 20 func init() { 20 21 sharedDefaultPolicy = buildDefaultPolicy() 21 22 sharedDescriptionPolicy = buildDescriptionPolicy() 23 + sharedLogsPolicy = buildLogsPolicy() 22 24 } 23 25 24 26 type Sanitizer struct { 25 27 defaultPolicy *bluemonday.Policy 26 28 descriptionPolicy *bluemonday.Policy 29 + logsPolicy *bluemonday.Policy 27 30 } 28 31 29 32 func NewSanitizer() Sanitizer { 30 33 return Sanitizer{ 31 34 defaultPolicy: sharedDefaultPolicy, 32 35 descriptionPolicy: sharedDescriptionPolicy, 36 + logsPolicy: sharedLogsPolicy, 33 37 } 34 38 } 35 39 ··· 38 42 } 39 43 func (s *Sanitizer) SanitizeDescription(html string) string { 40 44 return s.descriptionPolicy.Sanitize(html) 45 + } 46 + func (s *Sanitizer) SanitizeLogs(html string) string { 47 + return s.logsPolicy.Sanitize(html) 41 48 } 42 49 43 50 func buildDefaultPolicy() *bluemonday.Policy { ··· 153 160 154 161 return policy 155 162 } 163 + 164 + func buildLogsPolicy() *bluemonday.Policy { 165 + policy := bluemonday.NewPolicy() 166 + 167 + policy.AllowElements("p", "span") 168 + 169 + // allow italics and bold 170 + policy.AllowElements("i", "b", "em", "strong") 171 + 172 + // allow fg/bg classes from terminal-to-html 173 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`term-*`)).OnElements("span") 174 + 175 + return policy 176 + }
+1 -1
appview/pages/pages.go
··· 1536 1536 1537 1537 type LogLineParams struct { 1538 1538 Id int 1539 - Content string 1539 + Content template.HTML 1540 1540 } 1541 1541 1542 1542 func (p *Pages) LogLine(w io.Writer, params LogLineParams) error {
+144
appview/pipelines/ansi_test.go
··· 1 + package pipelines 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestAnsiState_SingleLine(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + input string 12 + wantContain string // substring expected in rendered HTML 13 + }{ 14 + {"bold", "\033[1mBold\033[0m", "term-fg1"}, 15 + {"red", "\033[31mRed\033[0m", "term-fg31"}, 16 + {"green", "\033[32mGreen\033[0m", "term-fg32"}, 17 + {"yellow", "\033[33mYellow\033[0m", "term-fg33"}, 18 + {"blue", "\033[34mBlue\033[0m", "term-fg34"}, 19 + {"magenta", "\033[35mMagenta\033[0m", "term-fg35"}, 20 + {"cyan", "\033[36mCyan\033[0m", "term-fg36"}, 21 + {"bold green", "\033[1;32mBold Green\033[0m", "term-fg32"}, 22 + {"red background", "\033[41m Red background \033[0m", "term-bg41"}, 23 + {"256 orange", "\033[38;5;208mANSI 256 orange\033[0m", "term-fgx208"}, 24 + // true color is stripped 25 + {"true color", "\033[38;2;255;100;0mTrue color orange\033[0m", "True color orange"}, 26 + } 27 + for _, tt := range tests { 28 + t.Run(tt.name, func(t *testing.T) { 29 + a := NewAnsiState() 30 + got := string(a.Render(tt.input)) 31 + t.Logf("%s → %s", tt.name, got) 32 + if !strings.Contains(got, tt.wantContain) { 33 + t.Errorf("expected output to contain %q, got: %s", tt.wantContain, got) 34 + } 35 + }) 36 + } 37 + } 38 + 39 + func TestAnsiState_MultiLine_CarryOver(t *testing.T) { 40 + // red opened on line 1 without reset — line 2 should carry it over. 41 + a := NewAnsiState() 42 + 43 + line1 := a.Render("\033[31mstart of red") 44 + if !strings.Contains(string(line1), "term-fg31") { 45 + t.Errorf("line1: expected term-fg31, got: %s", line1) 46 + } 47 + 48 + line2 := a.Render("still red\033[0m") 49 + t.Logf("line2 → %s", line2) 50 + if !strings.Contains(string(line2), "term-fg31") { 51 + t.Errorf("line2: expected carry-over term-fg31, got: %s", line2) 52 + } 53 + 54 + // after the reset, line 3 should have no colour. 55 + line3 := a.Render("plain text") 56 + t.Logf("line3 → %s", line3) 57 + if strings.Contains(string(line3), "term-fg31") { 58 + t.Errorf("line3: expected no term-fg31 after reset, got: %s", line3) 59 + } 60 + } 61 + 62 + func TestAnsiState_MultiLine_StackedSequences(t *testing.T) { 63 + // bold opened line 1, red added line 2 — both should carry to line 2. 64 + a := NewAnsiState() 65 + 66 + a.Render("\033[1mBold opened") 67 + 68 + line2 := a.Render("\033[31mRed added") 69 + t.Logf("line2 → %s", line2) 70 + if !strings.Contains(string(line2), "term-fg1") { 71 + t.Errorf("line2: expected carried-over term-fg1, got: %s", line2) 72 + } 73 + if !strings.Contains(string(line2), "term-fg31") { 74 + t.Errorf("line2: expected term-fg31, got: %s", line2) 75 + } 76 + 77 + a.Render("\033[0mReset") 78 + 79 + line4 := a.Render("plain") 80 + t.Logf("line4 → %s", line4) 81 + if strings.Contains(string(line4), "term-") { 82 + t.Errorf("line4: expected no term- classes after reset, got: %s", line4) 83 + } 84 + } 85 + 86 + func TestAnsiState_Reset_ClearsStack(t *testing.T) { 87 + a := NewAnsiState() 88 + a.Render("\033[31m\033[1m\033[33mMultiple opens") 89 + if len(a.stack) != 3 { 90 + t.Errorf("expected stack depth 3, got %d", len(a.stack)) 91 + } 92 + a.Render("\033[0mReset line") 93 + if len(a.stack) != 0 { 94 + t.Errorf("expected empty stack after reset, got %d: %v", len(a.stack), a.stack) 95 + } 96 + } 97 + 98 + func TestAnsiState_NoAnsi(t *testing.T) { 99 + a := NewAnsiState() 100 + got := string(a.Render("plain text no escapes")) 101 + t.Logf("got → %s", got) 102 + if strings.Contains(got, "term-") { 103 + t.Errorf("expected no term- classes for plain text, got: %s", got) 104 + } 105 + } 106 + 107 + func TestAnsiState_Sanitizer_XSS(t *testing.T) { 108 + tests := []struct { 109 + name string 110 + input string 111 + shuoldBeAbsent string // must not appear literally (unescaped) in output 112 + }{ 113 + { 114 + name: "script tag not executable", 115 + input: "<script>alert(1)</script>", 116 + shuoldBeAbsent: "<script>", 117 + }, 118 + { 119 + name: "img onerror not executable", 120 + input: `<img src=x onerror="alert(1)">`, 121 + shuoldBeAbsent: "<img", 122 + }, 123 + { 124 + name: "ansi with embedded script not executable", 125 + input: "\033[31m<script>alert(1)</script>\033[0m", 126 + shuoldBeAbsent: "<script>", 127 + }, 128 + { 129 + name: "only term- classes survive on span", 130 + input: "\033[31mcolored\033[0m", 131 + shuoldBeAbsent: "onclick", 132 + }, 133 + } 134 + for _, tt := range tests { 135 + t.Run(tt.name, func(t *testing.T) { 136 + a := NewAnsiState() 137 + got := string(a.Render(tt.input)) 138 + t.Logf("%s → %s", tt.name, got) 139 + if strings.Contains(got, tt.shuoldBeAbsent) { 140 + t.Errorf("expected %q to be absent (unescaped), got: %s", tt.shuoldBeAbsent, got) 141 + } 142 + }) 143 + } 144 + }
+44
appview/pipelines/logs.go
··· 1 1 package pipelines 2 2 3 3 import ( 4 + "html/template" 5 + "regexp" 4 6 "strings" 5 7 8 + terminal "github.com/buildkite/terminal-to-html/v3" 6 9 "github.com/gorilla/websocket" 10 + "tangled.org/core/appview/pages/markup" 7 11 ) 12 + 13 + // matches any ANSI escape sequence: ESC [ <params> m 14 + var sequenceRe = regexp.MustCompile(`\x1b\[([\d;]*)m`) 15 + 16 + // ansiState tracks the active stack across log lines 17 + // each non-reset SGR code is pushed onto the stack; a reset clears it. 18 + // 19 + // the stack contents are prepended to each new line so colours carry over. 20 + type ansiState struct { 21 + stack []string 22 + sanitizer markup.Sanitizer 23 + } 24 + 25 + func NewAnsiState() *ansiState { 26 + return &ansiState{ 27 + stack: []string{}, 28 + sanitizer: markup.NewSanitizer(), 29 + } 30 + } 31 + 32 + func (a *ansiState) Render(line string) template.HTML { 33 + // prepend whatever sequences are still open from the previous line 34 + prefix := strings.Join(a.stack, "") 35 + // render current line with the existing prefix 36 + rendered := terminal.Render([]byte(prefix + line)) 37 + // sanitize 38 + sanitized := a.sanitizer.SanitizeLogs(rendered) 39 + 40 + // update the stack with sequences from current line 41 + for _, m := range sequenceRe.FindAllStringSubmatch(line, -1) { 42 + params := m[1] 43 + if params == "" || params == "0" || params == "00" { 44 + a.stack = a.stack[:0] 45 + } else { 46 + a.stack = append(a.stack, m[0]) 47 + } 48 + } 49 + 50 + return template.HTML(sanitized) 51 + } 8 52 9 53 type LogEvent struct { 10 54 Msg []byte
+7 -2
appview/pipelines/pipelines.go
··· 257 257 go ReadLogs(spindleConn, evChan) 258 258 259 259 stepStartTimes := make(map[int]time.Time) 260 + stepAnsi := make(map[int]*ansiState) 260 261 var fragment bytes.Buffer 261 262 for { 262 263 select { ··· 313 314 } 314 315 315 316 case spindlemodel.LogKindData: 316 - // data messages simply insert new log lines into current step 317 + ansi, ok := stepAnsi[logLine.StepId] 318 + if !ok { 319 + ansi = NewAnsiState() 320 + stepAnsi[logLine.StepId] = ansi 321 + } 317 322 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 318 323 Id: logLine.StepId, 319 - Content: logLine.Content, 324 + Content: ansi.Render(logLine.Content), 320 325 }) 321 326 } 322 327 if err != nil {
+1
go.mod
··· 17 17 github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab 18 18 github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654 19 19 github.com/bmatcuk/doublestar/v4 v4.9.1 20 + github.com/buildkite/terminal-to-html/v3 v3.16.8 20 21 github.com/carlmjohnson/versioninfo v0.22.5 21 22 github.com/casbin/casbin/v2 v2.103.0 22 23 github.com/charmbracelet/bubbles v1.0.0
+2
go.sum
··· 124 124 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 125 125 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 126 126 github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 127 + github.com/buildkite/terminal-to-html/v3 v3.16.8 h1:QN/daUob6cmK8GcdKnwn9+YTlPr1vNj+oeAIiJK6fPc= 128 + github.com/buildkite/terminal-to-html/v3 v3.16.8/go.mod h1:+k1KVKROZocrTLsEQ9PEf9A+8+X8uaVV5iO1ZIOwKYM= 127 129 github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 128 130 github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 129 131 github.com/casbin/casbin/v2 v2.100.0/go.mod h1:LO7YPez4dX3LgoTCqSQAleQDo0S0BeZBDxYnPUl95Ng=
+58
input.css
··· 1442 1442 --hit-area-t: -3rem; 1443 1443 --hit-area-b: -3rem; 1444 1444 } 1445 + 1446 + /* terminal-to-html: catpuccin colors */ 1447 + .term-fg30 { color: #6c6f85; } /* black */ 1448 + .term-fg31 { color: #d20f39; } /* red */ 1449 + .term-fg32 { color: #40a02b; } /* green */ 1450 + .term-fg33 { color: #df8e1d; } /* yellow */ 1451 + .term-fg34 { color: #1e66f5; } /* blue */ 1452 + .term-fg35 { color: #8839ef; } /* magenta */ 1453 + .term-fg36 { color: #179299; } /* cyan */ 1454 + .term-fg37 { color: #4c4f69; } /* white */ 1455 + .term-fgi90 { color: #8c8fa1; } /* bright-black */ 1456 + .term-fgi91 { color: #e64553; } /* bright-red */ 1457 + .term-fgi92 { color: #40a02b; } /* bright-green */ 1458 + .term-fgi93 { color: #e5c890; } /* bright-yellow */ 1459 + .term-fgi94 { color: #209fb5; } /* bright-blue */ 1460 + .term-fgi95 { color: #ea76cb; } /* bright-magenta */ 1461 + .term-fgi96 { color: #04a5e5; } /* bright-cyan */ 1462 + .term-fgi97 { color: #bcc0cc; } /* bright-white */ 1463 + .term-bg40 { background-color: #6c6f85; } /* black */ 1464 + .term-bg41 { background-color: #d20f39; } /* red */ 1465 + .term-bg42 { background-color: #40a02b; } /* green */ 1466 + .term-bg43 { background-color: #df8e1d; } /* yellow */ 1467 + .term-bg44 { background-color: #1e66f5; } /* blue */ 1468 + .term-bg45 { background-color: #8839ef; } /* magenta */ 1469 + .term-bg46 { background-color: #179299; } /* cyan */ 1470 + .term-bg47 { background-color: #dce0e8; } /* white */ 1471 + .term-fg1 { @apply font-bold; } 1472 + .term-fg2 { @apply opacity-60; } 1473 + .term-fg3 { @apply italic; } 1474 + .term-fg4 { @apply underline; } 1475 + .term-fg9 { @apply line-through; } 1476 + 1477 + @media (prefers-color-scheme: dark) { 1478 + .term-fg30 { color: #a5adcb; } /* black */ 1479 + .term-fg31 { color: #ed8796; } /* red */ 1480 + .term-fg32 { color: #a6da95; } /* green */ 1481 + .term-fg33 { color: #eed49f; } /* yellow */ 1482 + .term-fg34 { color: #8aadf4; } /* blue */ 1483 + .term-fg35 { color: #c6a0f6; } /* magenta */ 1484 + .term-fg36 { color: #8bd5ca; } /* cyan */ 1485 + .term-fg37 { color: #cad3f5; } /* white */ 1486 + .term-fgi90 { color: #8087a2; } /* bright-black */ 1487 + .term-fgi91 { color: #f5bde6; } /* bright-red */ 1488 + .term-fgi92 { color: #a6da95; } /* bright-green */ 1489 + .term-fgi93 { color: #f5a97f; } /* bright-yellow */ 1490 + .term-fgi94 { color: #7dc4e4; } /* bright-blue */ 1491 + .term-fgi95 { color: #f4dbd6; } /* bright-magenta */ 1492 + .term-fgi96 { color: #91d7e3; } /* bright-cyan */ 1493 + .term-fgi97 { color: #f0c6c6; } /* bright-white */ 1494 + .term-bg40 { background-color: #a5adcb; } /* black */ 1495 + .term-bg41 { background-color: #ed8796; } /* red */ 1496 + .term-bg42 { background-color: #a6da95; } /* green */ 1497 + .term-bg43 { background-color: #eed49f; } /* yellow */ 1498 + .term-bg44 { background-color: #8aadf4; } /* blue */ 1499 + .term-bg45 { background-color: #c6a0f6; } /* magenta */ 1500 + .term-bg46 { background-color: #8bd5ca; } /* cyan */ 1501 + .term-bg47 { background-color: #494d64; } /* white */ 1502 + } 1445 1503 }
+3
tailwind.config.js
··· 2 2 const colors = require("tailwindcss/colors"); 3 3 4 4 module.exports = { 5 + safelist: [ 6 + { pattern: /^term-/ }, 7 + ], 5 8 content: [ 6 9 "./appview/pages/templates/**/*.html", 7 10 "./appview/pages/chroma.go",