prime8dev

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

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 }