--- /dev/null
+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<<bit) != 0 {
+ out.WriteString("\x1b[" + code + "m")
+ }
+ }
+
+ style = glyph.style
+ }
+
+ out.WriteRune(glyph.char)
+ }
+
+ if style != 0 {
+ out.WriteString("\x1b[0m")
+ }
+
+ return out.String()
+}
+
+// terminalMarkdownColumns returns the display width without SGR commands.
+func terminalMarkdownColumns(glyphs []TerminalMarkdownGlyph) int {
+ columns := 0
+ for _, glyph := range glyphs {
+ columns += glyph.columns
+ }
+
+ return columns
+}
+
+// terminalMarkdownGlyphText returns the text without its styles.
+func terminalMarkdownGlyphText(glyphs []TerminalMarkdownGlyph) string {
+ var out strings.Builder
+
+ for _, glyph := range glyphs {
+ out.WriteRune(glyph.char)
+ }
+
+ return out.String()
+}
--- /dev/null
+package main
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestTerminalMarkdownBlocksAndMarks(t *testing.T) {
+ testCases := []struct {
+ name string
+ text string
+ columns int
+ want []string
+ }{
+ {
+ name: "headings and a full width rule",
+ text: "# First\n###### Last ###\n---",
+ columns: 12,
+ want: []string{"\x1b[1mFirst\x1b[0m", "\x1b[1mLast\x1b[0m", "\x1b[2m------------\x1b[0m"},
+ },
+ {
+ name: "bold italic and code",
+ text: "**bold** __also__ *lean* _also_ `code`",
+ columns: 80,
+ want: []string{"\x1b[1mbold\x1b[0m \x1b[1malso\x1b[0m \x1b[3mlean\x1b[0m \x1b[3malso\x1b[0m \x1b[7mcode\x1b[0m"},
+ },
+ {
+ name: "nested marks restore the outer style",
+ text: "**bold *lean* end**",
+ columns: 80,
+ want: []string{"\x1b[1mbold \x1b[0m\x1b[1m\x1b[3mlean\x1b[0m\x1b[1m end\x1b[0m"},
+ },
+ {
+ name: "links show different URLs once",
+ text: "[site](https://example.com) [https://example.com](https://example.com)",
+ columns: 80,
+ want: []string{"site (https://example.com) https://example.com"},
+ },
+ {
+ name: "code lines keep literal marks and indent",
+ text: "```text\n**raw**\n code\n\n```",
+ columns: 80,
+ want: []string{" \x1b[2m**raw**\x1b[0m", " \x1b[2m code\x1b[0m", " "},
+ },
+ {
+ name: "quotes repeat the bar when wrapped",
+ text: "> 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~~ <details> < AT&T",
+ columns: 80,
+ want: []string{"*plain* snake_case ~~kept~~ <details> < 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, "")
+}