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<