+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()
+}