From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 02:30:51 +0000 (-0300) Subject: Render Markdown with plain terminal styles X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/209df5ce842e24b3c6e99a322fe3d98ab2df720b?ds=inline;hp=8fb85e5647f82e029956d81be29b3a5ac3490636 Render Markdown with plain terminal styles The terminal renderer wraps styled text and table cells to the display width. It uses bold, dim, italic, and reverse without colors, and keeps unclosed marks as text through the shared parser. Wide characters take two columns. The existing text module supplies width data, so vendoring includes that package without a new dependency. --- diff --git a/nix/package.nix b/nix/package.nix index f631044..bbd5fb6 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -10,7 +10,7 @@ buildGoModule { subPackages = [ "." ]; ldflags = [ "-X main.version=${version}" ]; - vendorHash = "sha256-35kAjo+7gbOOzNbKJyMYUcAF11HK7WzO3m9ZcrOsqY0="; + vendorHash = "sha256-HEYbx27w3s+x29/gDLUOCrkBJu9Ke1ue+Ms++LdHpyQ="; # The flake check runs the full replay. checkFlags = [ "-short" ]; diff --git a/terminal_markdown.go b/terminal_markdown.go new file mode 100644 index 0000000..a13a4e9 --- /dev/null +++ b/terminal_markdown.go @@ -0,0 +1,452 @@ +package main + +import ( + "strings" + "unicode" + + "golang.org/x/text/width" +) + +const ( + terminalMarkdownBold = 1 << iota + terminalMarkdownDim + terminalMarkdownItalic + terminalMarkdownReverse +) + +// TerminalMarkdownGlyph holds a character, its display width, and its style. +type TerminalMarkdownGlyph struct { + char rune + columns int + style int +} + +// terminalMarkdown renders Markdown as lines with terminal styles. Width is in +// display columns, with a default of 80. Styles end at each line boundary. +func terminalMarkdown(text string, columns int) []string { + if columns <= 0 { + columns = 80 + } + + lines := strings.Split(text, "\n") + + var out []string + + for index := 0; index < len(lines); index++ { + line := lines[index] + _, fence := markdownCodeFence(line) + end := markdownClosingFence(lines, index, fence) + heading := markdownHeadingText(line) + switch { + case end > index: + for _, code := range lines[index+1 : end] { + out = append(out, terminalMarkdownLines(terminalMarkdownPlain(code, terminalMarkdownDim), columns, " ", true)...) + } + + index = end + case strings.TrimSpace(line) == "---": + out = append(out, "\x1b[2m"+strings.Repeat("-", columns)+"\x1b[0m") + case index+1 < len(lines) && terminalMarkdownSeparator(lines[index+1]) && len(terminalMarkdownCells(line)) > 1: + rows := [][]string{terminalMarkdownCells(line)} + index += 2 + for index < len(lines) && strings.Contains(lines[index], "|") && strings.TrimSpace(lines[index]) != "" { + rows = append(rows, terminalMarkdownCells(lines[index])) + index++ + } + + index-- + out = append(out, terminalMarkdownTable(rows, columns)...) + case heading != "": + out = append(out, terminalMarkdownLines(terminalMarkdownInline(heading, terminalMarkdownBold), columns, "", false)...) + default: + prefix, content := terminalMarkdownPrefix(line) + out = append(out, terminalMarkdownLines(terminalMarkdownInline(content, 0), columns, prefix, false)...) + } + } + + return out +} + +// terminalMarkdownLines wraps glyphs with a hanging prefix. Literal lines keep +// their spaces. When a prefix leaves no text column, a list mark gets its own +// line and a quote uses a bar without a space or indent. A quote bar and one +// glyph, or a complete list mark, can exceed a narrower width rather than lose +// text. A wide character stays whole. +func terminalMarkdownLines(glyphs []TerminalMarkdownGlyph, columns int, prefix string, literal bool) []string { + var out []string + + if len(prefix) >= columns { + switch { + case literal: + prefix = "" + case strings.Contains(prefix, "|"): + prefix = "|" + default: + out = append(out, strings.TrimSpace(prefix)) + prefix = "" + } + } + + available := max(1, columns-len(prefix)) + + parts := terminalMarkdownWrap(glyphs, available, literal) + + for index, part := range parts { + mark := prefix + if index > 0 && !literal && !strings.Contains(prefix, "|") { + mark = strings.Repeat(" ", len(prefix)) + } + + out = append(out, mark+terminalMarkdownSGR(part)) + } + + return out +} + +// terminalMarkdownPlain makes display glyphs without reading inline marks. +// Control characters cannot insert terminal commands. Combining marks take no +// column and East Asian wide and fullwidth characters take two. +func terminalMarkdownPlain(text string, style int) []TerminalMarkdownGlyph { + var glyphs []TerminalMarkdownGlyph + + for _, char := range strings.ReplaceAll(text, "\t", " ") { + if unicode.IsControl(char) { + continue + } + + columns := 1 + switch { + case unicode.Is(unicode.Mn, char) || unicode.Is(unicode.Me, char) || char == '\u200d': + columns = 0 + case width.LookupRune(char).Kind() == width.EastAsianWide || width.LookupRune(char).Kind() == width.EastAsianFullwidth: + columns = 2 + } + + glyphs = append(glyphs, TerminalMarkdownGlyph{ + char: char, + columns: columns, + style: style, + }) + } + + return glyphs +} + +// terminalMarkdownSeparator tells if a line is a Markdown table separator. +func terminalMarkdownSeparator(line string) bool { + cells := terminalMarkdownCells(line) + if len(cells) < 2 { + return false + } + + for _, cell := range cells { + cell = strings.Trim(cell, ":") + if len(cell) < 3 || strings.Trim(cell, "-") != "" { + return false + } + } + + return true +} + +// terminalMarkdownCells splits a row outside code spans and escaped pipes. +func terminalMarkdownCells(line string) []string { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "|") + if strings.HasSuffix(line, "|") && !strings.HasSuffix(line, "\\|") { + line = strings.TrimSuffix(line, "|") + } + + var cells []string + + start := 0 + for index := 0; index < len(line); index++ { + switch line[index] { + case '\\': + index++ + case '`': + span, found := markdownCodeSpan(line, index) + if found { + index = span.end - 1 + } + case '|': + cells = append(cells, strings.TrimSpace(line[start:index])) + start = index + 1 + } + } + + return append(cells, strings.TrimSpace(line[start:])) +} + +// terminalMarkdownTable fits columns to the available width and wraps cells. +// When the separators and one glyph per column cannot fit, cells stack in row +// order. +func terminalMarkdownTable(rows [][]string, columns int) []string { + count := len(rows[0]) + + sizes := make([]int, count) + + minimum := make([]int, count) + cells := make([][][]TerminalMarkdownGlyph, len(rows)) + for row, values := range rows { + cells[row] = make([][]TerminalMarkdownGlyph, count) + for column := range count { + value := "" + if column < len(values) { + value = values[column] + } + + style := 0 + if row == 0 { + style = terminalMarkdownBold + } + + cells[row][column] = terminalMarkdownInline(value, style) + + sizes[column] = max(sizes[column], terminalMarkdownColumns(cells[row][column]), 1) + minimum[column] = max(minimum[column], 1) + for _, glyph := range cells[row][column] { + minimum[column] = max(minimum[column], glyph.columns) + } + } + } + + required := (count - 1) * 3 + for _, size := range minimum { + required += size + } + + if required > columns { + var out []string + + for _, row := range cells { + for _, cell := range row { + out = append(out, terminalMarkdownLines(cell, columns, "", false)...) + } + } + + return out + } + + budget := columns - (count-1)*3 + for { + total, largest := 0, 0 + for column, size := range sizes { + total += size + if size > minimum[column] && (sizes[largest] == minimum[largest] || size > sizes[largest]) { + largest = column + } + } + + if total <= budget { + break + } + + sizes[largest]-- + } + + var out []string + + for row, values := range cells { + wrapped := make([][][]TerminalMarkdownGlyph, count) + height := 1 + for column, value := range values { + wrapped[column] = terminalMarkdownWrap(value, sizes[column], false) + height = max(height, len(wrapped[column])) + } + + for line := range height { + parts := make([]string, count) + for column := range count { + var glyphs []TerminalMarkdownGlyph + + if line < len(wrapped[column]) { + glyphs = wrapped[column][line] + } + + parts[column] = terminalMarkdownSGR(glyphs) + strings.Repeat(" ", sizes[column]-terminalMarkdownColumns(glyphs)) + } + + out = append(out, strings.Join(parts, " | ")) + } + + if row == 0 { + rules := make([]string, count) + for column, size := range sizes { + rules[column] = strings.Repeat("-", size) + } + + out = append(out, "\x1b[2m"+strings.Join(rules, "-+-")+"\x1b[0m") + } + } + + return out +} + +// terminalMarkdownInline uses the shared marks, so unclosed marks stay as text. +// Strikethrough stays as text because the terminal styles exclude it. +func terminalMarkdownInline(line string, style int) []TerminalMarkdownGlyph { + var out []TerminalMarkdownGlyph + + for index := 0; index < len(line); { + if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 { + out = append(out, terminalMarkdownPlain(line[index+1:index+2], style)...) + index += 2 + + continue + } + + span, found := markdownMarkSpan(line, index) + if !found || span.kind == "strikethrough" { + end := index + 1 + for end < len(line) && !strings.ContainsRune("\\`[*_~", rune(line[end])) { + end++ + } + + out = append(out, terminalMarkdownPlain(line[index:end], style)...) + index = end + + continue + } + + mark := 0 + switch span.kind { + case "bold": + mark = terminalMarkdownBold + case "italic": + mark = terminalMarkdownItalic + case "code": + mark = terminalMarkdownReverse + } + + var content []TerminalMarkdownGlyph + + if span.kind == "code" { + content = terminalMarkdownPlain(span.content, style|mark) + } else { + content = terminalMarkdownInline(span.content, style|mark) + } + + out = append(out, content...) + if span.kind == "text_link" && terminalMarkdownGlyphText(content) != span.url { + out = append(out, terminalMarkdownPlain(" ("+span.url+")", style)...) + } + + index = span.end + } + + return out +} + +// terminalMarkdownPrefix returns the hanging mark of a quote or a list item. +func terminalMarkdownPrefix(line string) (string, string) { + trimmed := strings.TrimLeft(line, " ") + indent := line[:len(line)-len(trimmed)] + if strings.HasPrefix(trimmed, ">") { + return indent + "| ", strings.TrimPrefix(strings.TrimPrefix(trimmed, ">"), " ") + } + + if len(trimmed) > 1 && strings.ContainsRune("-*+", rune(trimmed[0])) && trimmed[1] == ' ' { + return indent + "- ", trimmed[2:] + } + + digits := 0 + for digits < len(trimmed) && trimmed[digits] >= '0' && trimmed[digits] <= '9' { + digits++ + } + + if digits > 0 && digits+1 < len(trimmed) && strings.ContainsRune(".)", rune(trimmed[digits])) && trimmed[digits+1] == ' ' { + return indent + trimmed[:digits+2], trimmed[digits+2:] + } + + return "", line +} + +// terminalMarkdownWrap splits at spaces when possible and otherwise at glyph +// boundaries. It always makes progress, even for a single wide glyph. +func terminalMarkdownWrap(glyphs []TerminalMarkdownGlyph, columns int, literal bool) [][]TerminalMarkdownGlyph { + var out [][]TerminalMarkdownGlyph + + for len(glyphs) > 0 { + end, used, space := 0, 0, -1 + for end < len(glyphs) && (used+glyphs[end].columns <= columns || end == 0) { + if glyphs[end].char == ' ' { + space = end + } + + used += glyphs[end].columns + end++ + } + + next := end + if !literal && end < len(glyphs) && glyphs[end].char != ' ' && space > 0 { + end = space + next = space + 1 + } + + out = append(out, glyphs[:end]) + glyphs = glyphs[next:] + if !literal { + for len(glyphs) > 0 && glyphs[0].char == ' ' { + glyphs = glyphs[1:] + } + } + } + + if len(out) == 0 { + out = append(out, nil) + } + + return out +} + +// terminalMarkdownSGR writes only the selected SGR styles and a final reset. +func terminalMarkdownSGR(glyphs []TerminalMarkdownGlyph) string { + var out strings.Builder + + style := 0 + for _, glyph := range glyphs { + if glyph.style != style { + if style != 0 { + out.WriteString("\x1b[0m") + } + + for bit, code := range []string{"1", "2", "3", "7"} { + if glyph.style&(1< first next last\n> end", + columns: 12, + want: []string{"| first next", "| last", "| end"}, + }, + { + name: "lists hang wrapped text after their marks", + text: "- first next last\n+ small\n* short\n12. first next last\n2) next", + columns: 12, + want: []string{"- first next", " last", "- small", "- short", "12. first", " next", " last", "2) next"}, + }, + { + name: "unclosed marks stay literal and cannot cross a line", + text: "**open\n*open\n_open\n`open\n[open](link\n```text\nplain", + columns: 80, + want: []string{"**open", "*open", "_open", "`open", "[open](link", "```text", "plain"}, + }, + { + name: "escaped marks and unsupported strike stay literal", + text: "\\*plain\\* snake_case ~~kept~~
< AT&T", + columns: 80, + want: []string{"*plain* snake_case ~~kept~~
< AT&T"}, + }, + { + name: "styled text wraps at words and resets every line", + text: "**first next last**", + columns: 10, + want: []string{"\x1b[1mfirst next\x1b[0m", "\x1b[1mlast\x1b[0m"}, + }, + { + name: "long words split without loss", + text: "abcdefghij", + columns: 4, + want: []string{"abcd", "efgh", "ij"}, + }, + { + name: "terminal commands are not passed through", + text: "safe\x1b[31m\tend", + columns: 80, + want: []string{"safe[31m end"}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, terminalMarkdown(tc.text, tc.columns)) + }) + } +} + +func TestTerminalMarkdownNarrowLinesKeepQuoteAndListMarks(t *testing.T) { + testCases := []struct { + name string + text string + columns int + want []string + }{ + { + name: "quote bar repeats without its space when width is two", + text: "> ab", + columns: 2, + want: []string{"|a", "|b"}, + }, + { + name: "bullet takes its own line when width is two", + text: "- ab", + columns: 2, + want: []string{"-", "ab"}, + }, + { + name: "full number takes its own line even when wider than width", + text: "12. ab", + columns: 2, + want: []string{"12.", "ab"}, + }, + { + name: "narrow quote drops indent but keeps its bar", + text: " > ab", + columns: 2, + want: []string{"|a", "|b"}, + }, + { + name: "one column keeps a quote bar and a whole glyph", + text: "> ab", + columns: 1, + want: []string{"|a", "|b"}, + }, + { + name: "one column keeps a bullet then wraps the item", + text: "- ab", + columns: 1, + want: []string{"-", "a", "b"}, + }, + { + name: "quote with room keeps its space on each line", + text: "> ab", + columns: 3, + want: []string{"| a", "| b"}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, terminalMarkdown(tc.text, tc.columns)) + }) + } +} + +func TestTerminalMarkdownTablesFitAndWrap(t *testing.T) { + text := "| Name | Detail |\n| :--- | ---: |\n| Short | Some longer words |\n| `a|b` | left \\| right |" + lines := terminalMarkdown(text, 23) + require.Equal(t, "Name | Detail ", terminalMarkdownWithoutSGR(lines[0])) + require.Equal(t, "\x1b[2m------+----------------\x1b[0m", lines[1]) + + var plain []string + + for _, line := range lines { + value := terminalMarkdownWithoutSGR(line) + require.LessOrEqual(t, terminalMarkdownColumns(terminalMarkdownPlain(value, 0)), 23) + + plain = append(plain, value) + } + + require.Contains(t, strings.Join(plain, "\n"), "Some") + require.Contains(t, strings.Join(plain, "\n"), "longer") + require.Contains(t, strings.Join(plain, "\n"), "a|b") + require.Contains(t, strings.Join(plain, "\n"), "left |") + require.Contains(t, lines[0], "\x1b[1mName\x1b[0m") +} + +func TestTerminalMarkdownSmallTablesKeepNaturalWidths(t *testing.T) { + lines := terminalMarkdown("Name | Size\n--- | ---\nOne | Big", 80) + require.Equal(t, []string{ + "\x1b[1mName\x1b[0m | \x1b[1mSize\x1b[0m", + "\x1b[2m-----+-----\x1b[0m", + "One | Big ", + }, lines) +} + +func TestTerminalMarkdownSingleCharacterColumnsStayNarrow(t *testing.T) { + require.Equal(t, []string{ + "\x1b[1mA\x1b[0m | \x1b[1mB\x1b[0m", + "\x1b[2m--+--\x1b[0m", + "x | y", + }, terminalMarkdown("A | B\n--- | ---\nx | y", 80)) +} + +func TestTerminalMarkdownCodeWrapKeepsSpacesAndMarks(t *testing.T) { + require.Equal(t, []string{ + " \x1b[2m ab\x1b[0m", + " \x1b[2m**cd\x1b[0m", + " \x1b[2m**\x1b[0m", + }, terminalMarkdown("```\n ab**cd**\n```", 6)) +} + +func TestTerminalMarkdownWideCharactersUseTwoColumns(t *testing.T) { + require.Equal(t, []string{"界界", "界"}, terminalMarkdown("界界界", 4)) + require.Equal(t, []string{"e界", "e\u0301"}, terminalMarkdown("e界e\u0301", 4)) + + lines := terminalMarkdown("Word | Mark\n--- | ---\n界界界 | eee", 13) + + var plain []string + + for _, line := range lines { + value := terminalMarkdownWithoutSGR(line) + require.LessOrEqual(t, terminalMarkdownColumns(terminalMarkdownPlain(value, 0)), 13) + + plain = append(plain, value) + } + + require.Contains(t, strings.Join(plain, "\n"), "界界") + require.Contains(t, strings.Join(plain, "\n"), "ee") +} + +func TestTerminalMarkdownTablesStackWhenColumnsCannotFit(t *testing.T) { + lines := terminalMarkdown("First | Second\n--- | ---\nalpha | beta", 4) + + var text strings.Builder + + for _, line := range lines { + plain := terminalMarkdownWithoutSGR(line) + require.LessOrEqual(t, len(plain), 4) + text.WriteString(plain) + } + + require.Equal(t, "FirstSecondalphabeta", text.String()) +} + +func TestTerminalMarkdownWidthDefaultsToEighty(t *testing.T) { + require.Equal(t, terminalMarkdown(strings.Repeat("a", 81), 80), terminalMarkdown(strings.Repeat("a", 81), 0)) +} + +func terminalMarkdownWithoutSGR(text string) string { + return regexp.MustCompile("\x1b\\[[0-9]+m").ReplaceAllString(text, "") +}