prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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 }