diff --git a/cmd/terminal-to-html/terminal-to-html.go b/cmd/terminal-to-html/terminal-to-html.go index 1ba3a1d..94f3d73 100644 --- a/cmd/terminal-to-html/terminal-to-html.go +++ b/cmd/terminal-to-html/terminal-to-html.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "runtime" + "slices" "strconv" "time" @@ -76,7 +77,7 @@ func writePreviewEnd(w io.Writer) error { return err } -func webservice(listen string, preview bool, maxLines int) { +func webservice(listen string, preview bool, maxLines int, format, timeFmt string) { http.HandleFunc("/terminal", func(w http.ResponseWriter, r *http.Request) { // Process the request body, but write to a buffer before serving it. // Consuming the body before any writes is necessary because of HTTP @@ -86,13 +87,20 @@ func webservice(listen string, preview bool, maxLines int) { // > Request.Body. // However, it lets us provide Content-Length in all cases. b := bytes.NewBuffer(nil) - if _, _, _, err := process(b, r.Body, preview, maxLines); err != nil { + if _, _, _, err := process(b, r.Body, preview, maxLines, format, timeFmt); err != nil { log.Printf("error starting preview: %v", err) w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, "Error creating preview.") } - w.Header().Set("Content-Type", "text/html") + switch format { + case "html": + w.Header().Set("Content-Type", "text/html") + case "plain": + w.Header().Set("Content-Type", "text/plain") + case "json", "json-plain": + w.Header().Set("Content-Type", "application/json") + } w.Header().Set("Content-Length", strconv.Itoa(b.Len())) if _, err := w.Write(b.Bytes()); err != nil { log.Printf("error writing response: %v", err) @@ -172,7 +180,7 @@ func (wc *writeCounter) Write(b []byte) (int, error) { // process streams the src through a terminal renderer to the dst. If preview is // true, the preview wrapper is added. -func process(dst io.Writer, src io.Reader, preview bool, maxLines int) (in, out int, screen *terminal.Screen, err error) { +func process(dst io.Writer, src io.Reader, preview bool, maxLines int, format, timeFmt string) (in, out int, screen *terminal.Screen, err error) { // Wrap dst in writeCounter to count bytes written wc := &writeCounter{out: dst} @@ -182,9 +190,33 @@ func process(dst io.Writer, src io.Reader, preview bool, maxLines int) (in, out } } + var scrollOutFunc func(*terminal.ScreenLine) + switch format { + case "html": + scrollOutFunc = func(line *terminal.ScreenLine) { fmt.Fprintln(wc, line.AsHTML(true)) } + case "plain": + scrollOutFunc = func(line *terminal.ScreenLine) { fmt.Fprintln(wc, line.AsPlain(timeFmt)) } + case "json": + enc := json.NewEncoder(wc) + scrollOutFunc = func(line *terminal.ScreenLine) { + _ = enc.Encode(map[string]any{ + "metadata": line.Metadata, + "content": line.AsHTML(false), // don't include timestamp in content, it's metadata + }) + } + case "json-plain": + enc := json.NewEncoder(wc) + scrollOutFunc = func(line *terminal.ScreenLine) { + _ = enc.Encode(map[string]any{ + "metadata": line.Metadata, + "content": line.AsPlain(""), // don't include timestamp in content, it's metadata + }) + } + } + screen = &terminal.Screen{ MaxLines: maxLines, - ScrollOutFunc: func(line string) { fmt.Fprintln(wc, line) }, + ScrollOutFunc: scrollOutFunc, } inBytes, err := io.Copy(screen, src) if err != nil { @@ -193,7 +225,16 @@ func process(dst io.Writer, src io.Reader, preview bool, maxLines int) (in, out // Write what remains in the screen buffer (everything that didn't scroll // out of the top). - fmt.Fprint(wc, screen.AsHTML()) + switch format { + case "html": + fmt.Fprint(wc, screen.AsHTML(true)) + case "plain": + fmt.Fprint(wc, screen.AsPlainText(timeFmt)) + case "json", "json-plain": + for _, line := range screen.Screen { + scrollOutFunc(&line) + } + } if preview { if err := writePreviewEnd(wc); err != nil { @@ -203,6 +244,8 @@ func process(dst io.Writer, src io.Reader, preview bool, maxLines int) (in, out return int(inBytes), wc.counter, screen, nil } +var allowedFormats = []string{"html", "plain", "json", "json-plain"} + func main() { cli.AppHelpTemplate = appHelpTemplate @@ -230,11 +273,42 @@ func main() { Value: 300, Usage: "Sets a limit on the number of lines to hold in the screen buffer, allowing the renderer to operate in a streaming fashion and enabling the processing of large inputs. Setting to 0 disables the limit, causing the renderer to buffer the entire screen before producing any output", }, + &cli.StringFlag{ + Name: "format", + Value: "html", + Usage: fmt.Sprintf("Configures output format. Must be one of %v", allowedFormats), + }, + &cli.StringFlag{ + Name: "timestamp-format", + Value: "rfc3339milli", + Usage: "Changes how timestamps are formatted (in plain format). Either 'none' (no timestamps), 'raw' (milliseconds since Unix epoch), 'rfc3339', 'rfc3339milli', or a custom Go time format string, used to format line timestamps for plain output (see https://pkg.go.dev/time#pkg-constants)", + }, } app.Action = func(c *cli.Context) error { + + format := c.String("format") + if !slices.Contains(allowedFormats, format) { + return fmt.Errorf("invalid format %q - must be one of %v", format, allowedFormats) + } + + // The preview HTML should only be added if the output format is HTML + // All other formats do not support being surrounded by extra HTML + preview := c.Bool("preview") && format == "html" + + // Timestamp format only applies to plain output. + timeFmt := c.String("timestamp-format") + switch timeFmt { + case "none": + timeFmt = "" + case "rfc3339": + timeFmt = time.RFC3339 + case "rfc3339milli": + timeFmt = "2006-01-02T15:04:05.999Z07:00" + } + // Run a web server? if addr := c.String("http"); addr != "" { - webservice(addr, c.Bool("preview"), c.Int("buffer-max-lines")) + webservice(addr, preview, c.Int("buffer-max-lines"), format, timeFmt) return nil } @@ -251,7 +325,14 @@ func main() { input = f } - in, out, screen, err := process(os.Stdout, input, c.Bool("preview"), c.Int("buffer-max-lines")) + in, out, screen, err := process( + os.Stdout, + input, + preview, + c.Int("buffer-max-lines"), + format, + timeFmt, + ) if err != nil { return err } diff --git a/output.go b/output.go index e7d1059..e240c89 100644 --- a/output.go +++ b/output.go @@ -4,7 +4,9 @@ import ( "fmt" "html" "sort" + "strconv" "strings" + "time" ) type outputBuffer struct { @@ -66,12 +68,12 @@ func (b *outputBuffer) appendChar(char rune) { } } -// asHTML returns the line with HTML formatting. -func (l *screenLine) asHTML() string { +// AsHTML returns the line with HTML formatting. +func (l *ScreenLine) AsHTML(withMetadata bool) string { var spanOpen bool var lineBuf outputBuffer - if data, ok := l.metadata[bkNamespace]; ok { + if data, ok := l.Metadata[bkNamespace]; ok && withMetadata { lineBuf.appendMeta(bkNamespace, data) } @@ -111,15 +113,59 @@ func (l *screenLine) asHTML() string { return line } -// asPlain returns the line contents without any added HTML. -func (l *screenLine) asPlain() string { +// AsPlain returns the line contents without any added HTML. +func (l *ScreenLine) AsPlain(timestampFormat string) string { var buf strings.Builder + if timestampFormat != "" { + buf.WriteString(bkTimestamp(l.Metadata["bk"]["t"], timestampFormat)) + buf.WriteRune('\t') + } + for _, node := range l.nodes { - if !node.style.element() { + if node.style.element() { + element := l.elements[node.blob] + switch element.elementType { + case ELEMENT_LINK: + content := element.content + if content == "" { + content = element.url + } + buf.WriteString(content) + + case ELEMENT_IMAGE: + content := element.alt + if content == "" { + content = element.url + } + buf.WriteString(content) + + case ELEMENT_ITERM_IMAGE: + + } + + } else { buf.WriteRune(node.blob) } } return strings.TrimRight(buf.String(), " \t") } + +func bkTimestamp(ts, timeFmt string) string { + if timeFmt == "raw" { + return ts + } + + if ts == "" { + return "(no timestamp)" + } + + tsint, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + return err.Error() + } + + tstime := time.UnixMilli(tsint) + return tstime.Format(timeFmt) +} diff --git a/parser_test.go b/parser_test.go index 0b06236..0e9af6a 100644 --- a/parser_test.go +++ b/parser_test.go @@ -90,7 +90,7 @@ func assertXY(t *testing.T, s *Screen, x, y int) error { } func assertText(t *testing.T, s *Screen, expected string) error { - if actual := s.AsPlainText(); actual != expected { + if actual := s.AsPlainText(""); actual != expected { return fmt.Errorf("expected text %q, got %q", expected, actual) } return nil diff --git a/screen.go b/screen.go index b8dcb0e..b6d04b1 100644 --- a/screen.go +++ b/screen.go @@ -12,7 +12,7 @@ type Screen struct { x, y int // Screen contents - screen []screenLine + Screen []ScreenLine // Current style style style @@ -25,8 +25,10 @@ type Screen struct { MaxLines int // Optional callback. If not nil, as each line is scrolled out of the top of - // the buffer, this func is called with the HTML. - ScrollOutFunc func(lineHTML string) + // the buffer, this func is called with the line. The screen recycles + // storage used for old lines when creating new ones, so the callback should + // avoid using the line after returning. + ScrollOutFunc func(line *ScreenLine) // Processing statistics LinesScrolledOut int // count of lines that scrolled off the top @@ -34,12 +36,12 @@ type Screen struct { CursorBackOOB int // count of times ESC [D tried to move x < 0 } -type screenLine struct { +type ScreenLine struct { nodes []node - // metadata is { namespace => { key => value, ... }, ... } + // Metadata is { namespace => { key => value, ... }, ... } // e.g. { "bk" => { "t" => "1234" } } - metadata map[string]map[string]string + Metadata map[string]map[string]string // element nodes refer to elements in this slice by index // (if node.style.element(), then elements[node.blob] is the element) @@ -54,7 +56,7 @@ const ( // Clear part (or all) of a line on the screen. The range to clear is inclusive // of xStart and xEnd. func (s *Screen) clear(y, xStart, xEnd int) { - if y < 0 || y >= len(s.screen) { + if y < 0 || y >= len(s.Screen) { return } @@ -66,7 +68,7 @@ func (s *Screen) clear(y, xStart, xEnd int) { return } - line := &s.screen[y] + line := &s.Screen[y] if xStart >= len(line.nodes) { // Clearing part of a line starting after the end of the current line... @@ -121,16 +123,16 @@ func (s *Screen) backward(i string) { } } -func (s *Screen) getCurrentLineForWriting() *screenLine { +func (s *Screen) getCurrentLineForWriting() *ScreenLine { // Ensure there are enough lines on screen for the cursor's Y position. - for s.y >= len(s.screen) { + for s.y >= len(s.Screen) { // If MaxLines is not in use, or adding a new line would not make it // larger than MaxLines, then just allocate a new line. - if s.MaxLines <= 0 || len(s.screen)+1 <= s.MaxLines { + if s.MaxLines <= 0 || len(s.Screen)+1 <= s.MaxLines { // nodes is preallocated with space for 80 columns, which is // arbitrary, but also the traditional terminal width. - newLine := screenLine{nodes: make([]node, 0, 80)} - s.screen = append(s.screen, newLine) + newLine := ScreenLine{nodes: make([]node, 0, 80)} + s.Screen = append(s.Screen, newLine) continue } @@ -138,18 +140,18 @@ func (s *Screen) getCurrentLineForWriting() *screenLine { // larger than MaxLines. // Pass the line being scrolled out to ScrollOutFunc if it exists. if s.ScrollOutFunc != nil { - s.ScrollOutFunc(s.screen[0].asHTML()) + s.ScrollOutFunc(&s.Screen[0]) } s.LinesScrolledOut++ // Trim the first line off the top of the screen. // Recycle its nodes slice to make a new line on the bottom. - newLine := screenLine{nodes: s.screen[0].nodes[:0]} - s.screen = append(s.screen[1:], newLine) + newLine := ScreenLine{nodes: s.Screen[0].nodes[:0]} + s.Screen = append(s.Screen[1:], newLine) s.y-- } - line := &s.screen[s.y] + line := &s.Screen[s.y] // Add columns if currently shorter than the cursor's x position for i := len(line.nodes); i <= s.x; i++ { @@ -191,17 +193,17 @@ func (s *Screen) appendElement(i *element) { // metadata for the current line, overwriting data when keys collide. func (s *Screen) setLineMetadata(namespace string, data map[string]string) { line := s.getCurrentLineForWriting() - if line.metadata == nil { - line.metadata = map[string]map[string]string{ + if line.Metadata == nil { + line.Metadata = map[string]map[string]string{ namespace: data, } return } - ns := line.metadata[namespace] + ns := line.Metadata[namespace] if ns == nil { // namespace did not exist, set all data - line.metadata[namespace] = data + line.Metadata[namespace] = data return } @@ -256,23 +258,23 @@ func (s *Screen) applyEscape(code rune, instructions []string) { // This line should be equivalent to K0 s.clear(s.y, s.x, screenEndOfLine) // Truncate the screen below the current line - if len(s.screen) > s.y { - s.screen = s.screen[:s.y+1] + if len(s.Screen) > s.y { + s.Screen = s.Screen[:s.y+1] } // "erase from beginning to current position (inclusive)" case "1": // This line should be equivalent to K1 s.clear(s.y, screenStartOfLine, s.x) // Truncate the screen above the current line - if len(s.screen) > s.y { - s.screen = s.screen[s.y+1:] + if len(s.Screen) > s.y { + s.Screen = s.Screen[s.y+1:] } // Adjust the cursor position to compensate s.y = 0 // 2: "erase entire display", 3: "erase whole display including scroll-back buffer" // Given we don't have a scrollback of our own, we treat these as equivalent case "2", "3": - s.screen = nil + s.Screen = nil s.x = 0 s.y = 0 } @@ -305,22 +307,25 @@ func (s *Screen) Write(input []byte) (int, error) { } // AsHTML returns the contents of the current screen buffer as HTML. -func (s *Screen) AsHTML() string { - lines := make([]string, 0, len(s.screen)) +func (s *Screen) AsHTML(withMetadata bool) string { + lines := make([]string, 0, len(s.Screen)) - for _, line := range s.screen { - lines = append(lines, line.asHTML()) + for _, line := range s.Screen { + lines = append(lines, line.AsHTML(withMetadata)) } return strings.Join(lines, "\n") } // AsPlainText renders the screen without any ANSI style etc. -func (s *Screen) AsPlainText() string { - lines := make([]string, 0, len(s.screen)) - - for _, line := range s.screen { - lines = append(lines, line.asPlain()) +// If timestampFormat is empty, then timestamps are not included. Otherwise, +// it is used to format the bk.t metadata for the line as a timestamp at the +// start of the line. +func (s *Screen) AsPlainText(timestampFormat string) string { + lines := make([]string, 0, len(s.Screen)) + + for _, line := range s.Screen { + lines = append(lines, line.AsPlain(timestampFormat)) } return strings.Join(lines, "\n") diff --git a/terminal.go b/terminal.go index 4d45fc8..e4554b8 100644 --- a/terminal.go +++ b/terminal.go @@ -14,5 +14,5 @@ package terminal func Render(input []byte) string { screen := Screen{} screen.Write(input) - return screen.AsHTML() + return screen.AsHTML(true) } diff --git a/terminal_test.go b/terminal_test.go index b92d9c1..3697d99 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -37,60 +37,85 @@ func base64Encode(stringToEncode string) string { } var rendererTestCases = []struct { - name string - input string - expected string + name string + input string + wantHTML string + wantPlain string }{ { - `input that ends in a newline will not include that newline`, - "hello\n", - "hello", - }, { - `closes colors that get opened`, - "he\033[32mllo", - "hello", - }, { - `treats multi-byte unicode characters as individual runes`, - "€€€€€€\b\b\baaa", - "€€€aaa", - }, { - `skips over colors when backspacing`, - "he\x1b[32m\x1b[33m\bllo", - "hllo", - }, { - `handles \x1b[m (no parameter) as a reset`, - "\x1b[36mthis has a color\x1b[mthis is normal now\r\n", - "this has a colorthis is normal now", - }, { - `treats \x1b[39m as a reset`, - "\x1b[36mthis has a color\x1b[39mthis is normal now\r\n", - "this has a colorthis is normal now", - }, { - `starts overwriting characters when you \r midway through something`, - "hello\rb", - "bello", - }, { - `colors across multiple lines`, - "\x1b[32mhello\n\nfriend\x1b[0m", - "hello\n \nfriend", - }, { - `allows you to control the cursor forwards`, - "this is\x1b[4Cpoop and stuff", - "this is poop and stuff", - }, { - `allows you to jump down further than the bottom of the buffer`, - "this is great \x1b[1Bhello", - "this is great\n hello", - }, { - `allows you to control the cursor backwards`, - "this is good\x1b[4Dpoop and stuff", - "this is poop and stuff", - }, { - `allows you to control the cursor upwards`, - "1234\n56\x1b[1A78\x1b[B", - "1278\n56", - }, { - `allows you to control the cursor downwards`, + name: `input that ends in a newline will not include that newline`, + input: "hello\n", + wantHTML: "hello", + wantPlain: "hello", + }, + { + name: `closes colors that get opened`, + input: "he\033[32mllo", + wantHTML: "hello", + wantPlain: "hello", + }, + { + name: `treats multi-byte unicode characters as individual runes`, + input: "€€€€€€\b\b\baaa", + wantHTML: "€€€aaa", + wantPlain: "€€€aaa", + }, + { + name: `skips over colors when backspacing`, + input: "he\x1b[32m\x1b[33m\bllo", + wantHTML: "hllo", + wantPlain: "hllo", + }, + { + name: `handles \x1b[m (no parameter) as a reset`, + input: "\x1b[36mthis has a color\x1b[mthis is normal now\r\n", + wantHTML: "this has a colorthis is normal now", + wantPlain: "this has a colorthis is normal now", + }, + { + name: `treats \x1b[39m as a reset`, + input: "\x1b[36mthis has a color\x1b[39mthis is normal now\r\n", + wantHTML: "this has a colorthis is normal now", + wantPlain: "this has a colorthis is normal now", + }, + { + name: `starts overwriting characters when you \r midway through something`, + input: "hello\rb", + wantHTML: "bello", + wantPlain: "bello", + }, + { + name: `colors across multiple lines`, + input: "\x1b[32mhello\n\nfriend\x1b[0m", + wantHTML: "hello\n \nfriend", + wantPlain: "hello\n\nfriend", + }, + { + name: `allows you to control the cursor forwards`, + input: "this is\x1b[4Cpoop and stuff", + wantHTML: "this is poop and stuff", + wantPlain: "this is poop and stuff", + }, + { + name: `allows you to jump down further than the bottom of the buffer`, + input: "this is great \x1b[1Bhello", + wantHTML: "this is great\n hello", + wantPlain: "this is great\n hello", + }, + { + name: `allows you to control the cursor backwards`, + input: "this is good\x1b[4Dpoop and stuff", + wantHTML: "this is poop and stuff", + wantPlain: "this is poop and stuff", + }, + { + name: `allows you to control the cursor upwards`, + input: "1234\n56\x1b[1A78\x1b[B", + wantHTML: "1278\n56", + wantPlain: "1278\n56", + }, + { + name: `allows you to control the cursor downwards`, // creates a grid of: // aaaa // bbbb @@ -98,240 +123,351 @@ var rendererTestCases = []struct { // Then goes up 2 rows, down 1 row, jumps to the begining // of the line, rewrites it to 1234, then jumps back down // to the end of the grid. - "aaaa\nbbbb\ncccc\x1b[2A\x1b[1B\r1234\x1b[1B", - "aaaa\n1234\ncccc", - }, { - `doesn't blow up if you go back too many characters`, - "this is good\x1b[100Dpoop and stuff", - "poop and stuff", - }, { - `doesn't blow up if you backspace too many characters`, - "hi\b\b\b\b\b\b\b\bbye", - "bye", - }, { - `\x1b[1K clears everything before it`, - "hello\x1b[1Kfriend!", - " friend!", - }, { - `clears everything after the \x1b[0K`, - "hello\nfriend!\x1b[A\r\x1b[0K", - " \nfriend!", - }, { - `handles \x1b[0G ghetto style`, - "hello friend\x1b[Ggoodbye buddy!", - "goodbye buddy!", - }, { - `preserves characters already written in a certain color`, - " \x1b[90m․\x1b[0m\x1b[90m․\x1b[0m\x1b[0G\x1b[90m․\x1b[0m\x1b[90m․\x1b[0m", - "․․․․", - }, { - `replaces empty lines with non-breaking spaces`, - "hello\n\nfriend", - "hello\n \nfriend", - }, { - `preserves opening colors when using \x1b[0G`, - "\x1b[33mhello\x1b[0m\x1b[33m\x1b[44m\x1b[0Ggoodbye", - "goodbye", - }, { - `allows clearing lines below the current line`, - "foo\nbar\x1b[A\x1b[Jbaz", - "foobaz", - }, { - `doesn't freak out about clearing lines below when there aren't any`, - "foobar\x1b[0J", - "foobar", - }, { - `allows clearing lines above the current line`, - "foo\nbar\x1b[A\x1b[1Jbaz", - "barbaz", - }, { - `doesn't freak out about clearing lines above when there aren't any`, - "\x1b[1Jfoobar", - "foobar", - }, { - `allows clearing the entire scrollback buffer with escape 2J`, - "this is a big long bit of terminal output\nplease pay it no mind, we will clear it soon\nokay, get ready for a disappearing act...\nand...and...\n\n\x1b[2Jhey presto", - "hey presto", - }, { - `allows clearing the entire scrollback buffer with escape 3J also`, - "this is a big long bit of terminal output\nplease pay it no mind, we will clear it soon\nokay, get ready for a disappearing act...\nand...and...\n\n\x1b[2Jhey presto", - "hey presto", - }, { - `allows erasing the current line up to a point`, - "hello friend\x1b[1K!", - " !", - }, { - `allows clearing of the current line`, - "hello friend\x1b[2K!", - " !", - }, { - `doesn't close spans if no colors have been opened`, - "hello \x1b[0mfriend", - "hello friend", - }, { - `\x1b[K correctly clears all previous parts of the string`, - "remote: Compressing objects: 0% (1/3342)\x1b[K\rremote: Compressing objects: 1% (34/3342)", - "remote: Compressing objects: 1% (34/3342)", - }, { - `handles reverse linefeed`, - "meow\npurr\nnyan\x1bMrawr", - "meow\npurrrawr\nnyan", - }, { - `collapses many spans of the same color into 1`, - "\x1b[90m․\x1b[90m․\x1b[90m․\x1b[90m․\n\x1b[90m․\x1b[90m․\x1b[90m․\x1b[90m․", - "․․․․\n․․․․", - }, { - `escapes HTML`, - "hello friend", - "hello <strong>friend</strong>", - }, { - `escapes HTML in color codes`, - "hello \x1b[\"hellomfriend", - "hello ["hellomfriend", - }, { - `handles background colors`, - "\x1b[30;42m\x1b[2KOK (244 tests, 558 assertions)", - "OK (244 tests, 558 assertions)", - }, { - `does not attempt to incorrectly nest CSS in HTML (https://github.com/buildkite/terminal-to-html/issues/36)`, - "Some plain text\x1b[0;30;42m yay a green background \x1b[0m\x1b[0;33;49mnow this has no background but is yellow \x1b[0m", - "Some plain text yay a green background now this has no background but is yellow ", - }, { - `handles xterm colors`, - "\x1b[38;5;169;48;5;50mhello\x1b[0m \x1b[38;5;179mgoodbye", - "hello goodbye", - }, { - `handles non-xterm codes on the same line as xterm colors`, - "\x1b[38;5;228;5;1mblinking and bold\x1b", - `blinking and bold`, - }, { - `ignores broken escape characters, stripping the escape rune itself`, - "hi amazing \x1b[12 nom nom nom friends", - "hi amazing [12 nom nom nom friends", - }, { - `handles colors with 3 attributes`, - "\x1b[0;10;4m\x1b[1m\x1b[34mgood news\x1b[0;10m\n\neveryone", - "good news\n \neveryone", - }, { - `ends underlining with \x1b[24`, - "\x1b[4mbegin\x1b[24m\r\nend", - "begin\nend", - }, { - `ends bold with \x1b[21`, - "\x1b[1mbegin\x1b[21m\r\nend", - "begin\nend", - }, { - `ends bold with \x1b[22`, - "\x1b[1mbegin\x1b[22m\r\nend", - "begin\nend", - }, { - `ends crossed out with \x1b[29`, - "\x1b[9mbegin\x1b[29m\r\nend", - "begin\nend", - }, { - `ends italic out with \x1b[23`, - "\x1b[3mbegin\x1b[23m\r\nend", - "begin\nend", - }, { - `ends decreased intensity with \x1b[22`, - "\x1b[2mbegin\x1b[22m\r\nend", - "begin\nend", - }, { - `ignores cursor show/hide`, - "\x1b[?25ldoing a thing without a cursor\x1b[?25h", - "doing a thing without a cursor", - }, { - `renders simple images on their own line`, // http://iterm2.com/images.html - "hi\x1b]1337;File=name=MS5naWY=;inline=1:AA==\ahello", - "hi\n" + `1.gif` + "\nhello", - }, { - `does not start a new line for iterm images if we're already at the start of a line`, - "\x1b]1337;File=name=MS5naWY=;inline=1:AA==\a", - `1.gif`, - }, { - `silently ignores unsupported ANSI escape sequences`, - "abc\x1b]9999\aghi", - "abcghi", - }, { - `correctly handles images that we decide not to render`, - "hi\x1b]1337;File=name=MS5naWY=;inline=0:AA==\ahello", - "hihello", - }, { - `renders external images`, - "\x1b]1338;url=http://foo.com/foobar.gif;alt=foo bar\a", - `foo bar`, - }, { - `disallows non-allow-listed schemes for images`, - "before\x1b]1338;url=javascript:alert(1);alt=hello\x07after", - "before\n \nafter", // don't really care about the middle, as long as it's white-spacey - }, { - `renders links, and renders them inline on other content`, - "a link to \x1b]1339;url=http://google.com;content=google\a.", - `a link to google.`, - }, { - `uses URL as link content if missing`, - "\x1b]1339;url=http://google.com\a", - `http://google.com`, - }, { - `protects inline images against XSS by escaping HTML during rendering`, - "hi\x1b]1337;File=name=" + base64Encode("