markdown.go (4391B)
1 package tui 2 3 import ( 4 "regexp" 5 "strings" 6 7 termmarkdown "github.com/MichaelMure/go-term-markdown" 8 ) 9 10 var ( 11 ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*m`) 12 urlRE = regexp.MustCompile(`https?://[^\s)]+`) 13 headingNumberTokenRE = regexp.MustCompile(`^\d+(?:\.\d+)+$`) 14 horizontalRuleLineRE = regexp.MustCompile(`^\s*─+\s*$`) 15 alphaNumericRE = regexp.MustCompile(`[A-Za-z0-9]`) 16 ansiArtGlyphRE = regexp.MustCompile(`[█▓▒░▄▀▌▐▖▗▘▙▚▛▜▝▞▟⣿⣀-⣿]`) 17 markdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\([^\)]+\)`) 18 htmlImageRE = regexp.MustCompile(`(?i)<img\b`) 19 ) 20 21 func renderMarkdown(markdown string, width int) string { 22 markdown = strings.TrimSpace(markdown) 23 if markdown == "" { 24 return "" 25 } 26 27 width = max(width, 20) 28 renderWidth := markdownRenderWidth(markdown, width) 29 out := termmarkdown.Render(markdown, renderWidth, 0) 30 if len(out) == 0 { 31 return markdown 32 } 33 34 return styleRenderedMarkdown(strings.TrimRight(string(out), "\n")) 35 } 36 37 func styleRenderedMarkdown(rendered string) string { 38 lines := strings.Split(rendered, "\n") 39 plainLines := make([]string, len(lines)) 40 preserveANSI := make([]bool, len(lines)) 41 for i := range lines { 42 plainLines[i] = ansiEscapeRE.ReplaceAllString(lines[i], "") 43 preserveANSI[i] = shouldPreserveANSILine(lines[i], plainLines[i]) 44 } 45 46 headingOpen := false 47 48 for i := range lines { 49 if preserveANSI[i] { 50 continue 51 } 52 53 plainLine := plainLines[i] 54 trimmed := strings.TrimSpace(plainLine) 55 if trimmed == "" { 56 lines[i] = "" 57 headingOpen = false 58 continue 59 } 60 if horizontalRuleLineRE.MatchString(trimmed) { 61 lines[i] = dimStyle.Render(plainLine) 62 headingOpen = false 63 continue 64 } 65 66 line, headingStart := stripHeadingNumber(plainLine, plainLines, i) 67 if headingStart { 68 lines[i] = titleStyle.Render(line) 69 headingOpen = true 70 continue 71 } 72 if headingOpen { 73 lines[i] = titleStyle.Render(plainLine) 74 continue 75 } 76 77 lines[i] = styleBodyLine(plainLine) 78 } 79 80 return strings.Join(lines, "\n") 81 } 82 83 func markdownRenderWidth(markdown string, width int) int { 84 if !markdownHasImages(markdown) { 85 return width 86 } 87 88 reduced := int(float64(width) * 0.72) 89 return max(min(reduced, width), 20) 90 } 91 92 func markdownHasImages(markdown string) bool { 93 return markdownImageRE.MatchString(markdown) || htmlImageRE.MatchString(markdown) 94 } 95 96 func shouldPreserveANSILine(raw string, plain string) bool { 97 if !ansiEscapeRE.MatchString(raw) { 98 return false 99 } 100 101 trimmed := strings.TrimSpace(plain) 102 if trimmed == "" { 103 return true 104 } 105 if alphaNumericRE.MatchString(trimmed) { 106 return false 107 } 108 109 return ansiArtGlyphRE.MatchString(trimmed) 110 } 111 112 func stripHeadingNumber(line string, lines []string, lineIdx int) (string, bool) { 113 trimmed := strings.TrimSpace(line) 114 if trimmed == "" { 115 return line, false 116 } 117 118 parts := strings.Fields(trimmed) 119 if len(parts) < 2 { 120 return line, false 121 } 122 123 token := parts[0] 124 isOrderedList := strings.HasSuffix(token, ".") 125 isNestedHeading := headingNumberTokenRE.MatchString(token) 126 isTopLevelHeading := isDigitsOnly(token) && hasRuleBeforeBlank(lines, lineIdx) 127 if isOrderedList || (!isNestedHeading && !isTopLevelHeading) { 128 return line, false 129 } 130 131 leading := len(line) - len(strings.TrimLeft(line, " ")) 132 body := strings.TrimLeft(line[leading:], " ") 133 if !strings.HasPrefix(body, token) { 134 return line, false 135 } 136 137 body = strings.TrimLeft(body[len(token):], " ") 138 return line[:leading] + body, true 139 } 140 141 func hasRuleBeforeBlank(lines []string, lineIdx int) bool { 142 for i := lineIdx + 1; i < len(lines); i++ { 143 trimmed := strings.TrimSpace(lines[i]) 144 if trimmed == "" { 145 return false 146 } 147 if horizontalRuleLineRE.MatchString(trimmed) { 148 return true 149 } 150 } 151 152 return false 153 } 154 155 func styleBodyLine(line string) string { 156 matches := urlRE.FindAllStringIndex(line, -1) 157 if len(matches) == 0 { 158 return rowStyle.Render(line) 159 } 160 161 var b strings.Builder 162 cursor := 0 163 for _, match := range matches { 164 if match[0] > cursor { 165 b.WriteString(rowStyle.Render(line[cursor:match[0]])) 166 } 167 b.WriteString(linkStyle.Render(line[match[0]:match[1]])) 168 cursor = match[1] 169 } 170 if cursor < len(line) { 171 b.WriteString(rowStyle.Render(line[cursor:])) 172 } 173 174 return b.String() 175 } 176 177 func isDigitsOnly(s string) bool { 178 if s == "" { 179 return false 180 } 181 182 for _, ch := range s { 183 if ch < '0' || ch > '9' { 184 return false 185 } 186 } 187 188 return true 189 }