project_markdown_render.go (4334B)
1 package tui 2 3 import ( 4 "strconv" 5 "strings" 6 7 tea "github.com/charmbracelet/bubbletea" 8 ) 9 10 type projectMarkdownRenderedMsg struct { 11 projectID string 12 width int 13 rendered string 14 } 15 16 func (m portfolioModel) cachedProjectMarkdown(projectID string, width int) (string, bool) { 17 byWidth, ok := m.projectReadmeRender[projectID] 18 if !ok { 19 return "", false 20 } 21 22 rendered, ok := byWidth[width] 23 return rendered, ok 24 } 25 26 func (m *portfolioModel) setCachedProjectMarkdown(projectID string, width int, rendered string) { 27 byWidth := m.projectReadmeRender[projectID] 28 if byWidth == nil { 29 byWidth = make(map[int]string) 30 m.projectReadmeRender[projectID] = byWidth 31 } 32 33 byWidth[width] = rendered 34 } 35 36 func projectMarkdownLoadKey(projectID string, width int) string { 37 return projectID + ":" + strconv.Itoa(width) 38 } 39 40 func (m *portfolioModel) maybeRenderSelectedProjectMarkdownCmd() tea.Cmd { 41 if m.activeTab != tabProjects { 42 return nil 43 } 44 45 project := m.selectedProject() 46 if project == nil { 47 return nil 48 } 49 50 readme, ok := m.projectReadmes[project.ID] 51 if !ok || strings.TrimSpace(readme) == "" { 52 return nil 53 } 54 55 width := m.projectMarkdownWidth() 56 if _, ok := m.cachedProjectMarkdown(project.ID, width); ok { 57 return nil 58 } 59 60 loadKey := projectMarkdownLoadKey(project.ID, width) 61 if m.projectMarkdownLoads[loadKey] { 62 return nil 63 } 64 65 tagRow := renderTagList(project.Tech) 66 patched := replaceFirstH1WithSourceLink(readme, project.Name, project.URL) 67 68 m.projectMarkdownLoads[loadKey] = true 69 return renderProjectMarkdownCmd(project.ID, patched, tagRow, width) 70 } 71 72 func renderProjectMarkdownCmd(projectID string, markdown string, techRow string, width int) tea.Cmd { 73 return func() tea.Msg { 74 rendered := renderMarkdown(markdown, width) 75 if rendered == "" { 76 rendered = markdown 77 } 78 rendered = outdentRenderedBody(rendered) 79 if strings.TrimSpace(techRow) != "" { 80 rendered = insertTechRowBelowRenderedH1(rendered, techRow) 81 } 82 83 return projectMarkdownRenderedMsg{ 84 projectID: projectID, 85 width: width, 86 rendered: rendered, 87 } 88 } 89 } 90 91 func insertTechRowBelowRenderedH1(rendered string, techRow string) string { 92 if strings.TrimSpace(techRow) == "" { 93 return rendered 94 } 95 96 lines := strings.Split(rendered, "\n") 97 headIdx := -1 98 for i, line := range lines { 99 plain := strings.TrimSpace(ansiEscapeRE.ReplaceAllString(line, "")) 100 if plain != "" { 101 headIdx = i 102 break 103 } 104 } 105 if headIdx == -1 { 106 if strings.TrimSpace(rendered) == "" { 107 return techRow 108 } 109 return techRow + "\n\n" + rendered 110 } 111 112 insertAt := headingBlockEndIndex(lines, headIdx) 113 114 bodyStart := insertAt 115 for bodyStart < len(lines) && strings.TrimSpace(ansiEscapeRE.ReplaceAllString(lines[bodyStart], "")) == "" { 116 bodyStart++ 117 } 118 119 header := strings.Join(lines[:insertAt], "\n") 120 if bodyStart >= len(lines) { 121 if strings.TrimSpace(header) == "" { 122 return techRow 123 } 124 return header + "\n" + techRow 125 } 126 127 body := strings.Join(lines[bodyStart:], "\n") 128 return header + "\n" + techRow + "\n\n" + body 129 } 130 131 func headingBlockEndIndex(lines []string, headIdx int) int { 132 for i := headIdx + 1; i < len(lines); i++ { 133 plain := strings.TrimSpace(ansiEscapeRE.ReplaceAllString(lines[i], "")) 134 if horizontalRuleLineRE.MatchString(plain) { 135 return i + 1 136 } 137 if plain == "" { 138 return i 139 } 140 } 141 return len(lines) 142 } 143 144 func outdentRenderedBody(rendered string) string { 145 lines := strings.Split(rendered, "\n") 146 if len(lines) < 2 { 147 return rendered 148 } 149 150 headIdx := -1 151 for i, line := range lines { 152 if strings.TrimSpace(line) != "" { 153 headIdx = i 154 break 155 } 156 } 157 if headIdx == -1 || headIdx == len(lines)-1 { 158 return rendered 159 } 160 161 bodyStart := headIdx + 1 162 for bodyStart < len(lines) && strings.TrimSpace(lines[bodyStart]) == "" { 163 bodyStart++ 164 } 165 if bodyStart >= len(lines) { 166 return rendered 167 } 168 169 outdent := 0 170 for _, line := range lines[bodyStart:] { 171 if strings.TrimSpace(line) == "" || !strings.HasPrefix(line, " ") { 172 continue 173 } 174 indent := 0 175 for indent < len(line) && line[indent] == ' ' { 176 indent++ 177 } 178 if outdent == 0 || indent < outdent { 179 outdent = indent 180 } 181 } 182 if outdent == 0 { 183 return rendered 184 } 185 186 for i := bodyStart; i < len(lines); i++ { 187 indent := 0 188 for indent < len(lines[i]) && indent < outdent && lines[i][indent] == ' ' { 189 indent++ 190 } 191 lines[i] = lines[i][indent:] 192 } 193 194 return strings.Join(lines, "\n") 195 }