1package tui
2
3import (
4 "regexp"
5 "strings"
6)
7
8// RenderMarkdown renders a small subset of Markdown to styled terminal
9// text using theme colors. Supported: headings, bold, italic, inline
10// code, fenced code blocks, bullet lists, numbered lists, blockquotes.
11// Not supported: tables, links with complex formatting, HTML.
12//
13// width is used to draw horizontal rules (e.g. around code fences).
14// Pass 0 to use a reasonable fallback.
15func RenderMarkdown(src string, th Theme, width int) string {
16 if width <= 0 {
17 width = 80
18 }
19 rule := th.FG256(th.Muted, strings.Repeat("─", width))
20
21 lines := strings.Split(src, "\n")
22 var out strings.Builder
23 var fenceBuf strings.Builder
24 inFence := false
25 fenceLang := ""
26 fenceIndent := ""
27
28 flushFence := func() {
29 if fenceBuf.Len() == 0 {
30 return
31 }
32 code := strings.TrimRight(fenceBuf.String(), "\n")
33 if fenceLang != "" {
34 for _, l := range HighlightCode(code, fenceLang) {
35 out.WriteString(l)
36 out.WriteString("\n")
37 }
38 } else {
39 for _, l := range strings.Split(code, "\n") {
40 out.WriteString(th.FG256(th.Accent, l))
41 out.WriteString("\n")
42 }
43 }
44 fenceBuf.Reset()
45 }
46
47 for _, line := range lines {
48 trim := strings.TrimLeft(line, " ")
49 if strings.HasPrefix(trim, "```") {
50 if inFence {
51 flushFence()
52 inFence = false
53 fenceLang = ""
54 out.WriteString(rule + "\n")
55 } else {
56 inFence = true
57 fenceIndent = line[:len(line)-len(trim)]
58 fenceLang = strings.TrimSpace(strings.TrimPrefix(trim, "```"))
59 out.WriteString(rule + "\n")
60 }
61 continue
62 }
63 if inFence {
64 if strings.HasPrefix(line, fenceIndent) {
65 line = line[len(fenceIndent):]
66 }
67 fenceBuf.WriteString(line)
68 fenceBuf.WriteString("\n")
69 continue
70 }
71 // Headings.
72 if m := headingRE.FindStringSubmatch(line); m != nil {
73 level := len(m[1])
74 body := strings.TrimSpace(m[2])
75 prefix := strings.Repeat("#", level) + " "
76 out.WriteString(Bold(th.FG256(th.Accent, prefix+body)) + "\n")
77 continue
78 }
79 // Blockquote.
80 if strings.HasPrefix(trim, "> ") {
81 body := strings.TrimPrefix(trim, "> ")
82 out.WriteString(th.FG256(th.Muted, "┃ ") + renderInline(body, th) + "\n")
83 continue
84 }
85 // Bullet list.
86 if m := bulletRE.FindStringSubmatch(line); m != nil {
87 indent, body := m[1], m[2]
88 out.WriteString(indent + th.FG256(th.Accent, "• ") + renderInline(body, th) + "\n")
89 continue
90 }
91 // Numbered list.
92 if m := numberRE.FindStringSubmatch(line); m != nil {
93 indent, num, body := m[1], m[2], m[3]
94 out.WriteString(indent + th.FG256(th.Accent, num+". ") + renderInline(body, th) + "\n")
95 continue
96 }
97 out.WriteString(renderInline(line, th) + "\n")
98 }
99 return strings.TrimRight(out.String(), "\n")
100}
101
102var (
103 headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
104 bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`)
105 numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`)
106
107 boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
108 italRE = regexp.MustCompile(`\*([^*]+)\*`)
109 codeRE = regexp.MustCompile("`([^`]+)`")
110)
111
112func renderInline(s string, th Theme) string {
113 s = codeRE.ReplaceAllStringFunc(s, func(m string) string {
114 inner := m[1 : len(m)-1]
115 return th.FG256(th.Accent, inner)
116 })
117 s = boldRE.ReplaceAllStringFunc(s, func(m string) string {
118 inner := m[2 : len(m)-2]
119 return Bold(inner)
120 })
121 s = italRE.ReplaceAllStringFunc(s, func(m string) string {
122 inner := m[1 : len(m)-1]
123 return Italic(inner)
124 })
125 return s
126}