markdown.go (4046B)
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 ) 18 19 func renderMarkdown(markdown string, width int) string { 20 markdown = strings.TrimSpace(markdown) 21 if markdown == "" { 22 return "" 23 } 24 25 width = max(width, 20) 26 source, images := extractImages(markdown, width) 27 source, wide := extractWideBlocks(source, width) 28 out := termmarkdown.Render(source, width, 0) 29 if len(out) == 0 { 30 return markdown 31 } 32 33 styled := styleRenderedMarkdown(strings.TrimRight(string(out), "\n")) 34 return restoreImages(restoreWideBlocks(styled, wide), images) 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 shouldPreserveANSILine(raw string, plain string) bool { 84 if !ansiEscapeRE.MatchString(raw) { 85 return false 86 } 87 88 trimmed := strings.TrimSpace(plain) 89 if trimmed == "" { 90 return true 91 } 92 if alphaNumericRE.MatchString(trimmed) { 93 return false 94 } 95 96 return ansiArtGlyphRE.MatchString(trimmed) 97 } 98 99 func stripHeadingNumber(line string, lines []string, lineIdx int) (string, bool) { 100 trimmed := strings.TrimSpace(line) 101 if trimmed == "" { 102 return line, false 103 } 104 105 parts := strings.Fields(trimmed) 106 if len(parts) < 2 { 107 return line, false 108 } 109 110 token := parts[0] 111 isOrderedList := strings.HasSuffix(token, ".") 112 isNestedHeading := headingNumberTokenRE.MatchString(token) 113 isTopLevelHeading := isDigitsOnly(token) && hasRuleBeforeBlank(lines, lineIdx) 114 if isOrderedList || (!isNestedHeading && !isTopLevelHeading) { 115 return line, false 116 } 117 118 leading := len(line) - len(strings.TrimLeft(line, " ")) 119 body := strings.TrimLeft(line[leading:], " ") 120 if !strings.HasPrefix(body, token) { 121 return line, false 122 } 123 124 body = strings.TrimLeft(body[len(token):], " ") 125 return line[:leading] + body, true 126 } 127 128 func hasRuleBeforeBlank(lines []string, lineIdx int) bool { 129 for i := lineIdx + 1; i < len(lines); i++ { 130 trimmed := strings.TrimSpace(lines[i]) 131 if trimmed == "" { 132 return false 133 } 134 if horizontalRuleLineRE.MatchString(trimmed) { 135 return true 136 } 137 } 138 139 return false 140 } 141 142 func styleBodyLine(line string) string { 143 matches := urlRE.FindAllStringIndex(line, -1) 144 if len(matches) == 0 { 145 return rowStyle.Render(line) 146 } 147 148 var b strings.Builder 149 cursor := 0 150 for _, match := range matches { 151 if match[0] > cursor { 152 b.WriteString(rowStyle.Render(line[cursor:match[0]])) 153 } 154 b.WriteString(linkStyle.Render(line[match[0]:match[1]])) 155 cursor = match[1] 156 } 157 if cursor < len(line) { 158 b.WriteString(rowStyle.Render(line[cursor:])) 159 } 160 161 return b.String() 162 } 163 164 func isDigitsOnly(s string) bool { 165 if s == "" { 166 return false 167 } 168 169 for _, ch := range s { 170 if ch < '0' || ch > '9' { 171 return false 172 } 173 } 174 175 return true 176 }