project_markdown_render.go (4760B)
1 package tui 2 3 import "strings" 4 5 // README rendering is synchronous and on demand: whenever the detail pane is 6 // about to show the selected project (syncViewport: selection, tab switch, 7 // resize, a README arriving), its README is rendered for the current width if 8 // that width is not cached yet. Rendering takes a few milliseconds, and doing 9 // it inline means the pane can never show the raw markdown while an async 10 // render is in flight, or keep showing it because a render result was 11 // dropped or landed for a width/README that was no longer current (what the 12 // old async path did until something happened to re-render the pane). 13 14 func (m portfolioModel) cachedProjectMarkdown(projectID string, width int) (string, bool) { 15 byWidth, ok := m.projectReadmeRender[projectID] 16 if !ok { 17 return "", false 18 } 19 20 rendered, ok := byWidth[width] 21 return rendered, ok 22 } 23 24 // maxCachedReadmeWidths bounds the per-project cache while a window is 25 // being resized through many widths. 26 const maxCachedReadmeWidths = 8 27 28 func (m *portfolioModel) setCachedProjectMarkdown(projectID string, width int, rendered string) { 29 byWidth := m.projectReadmeRender[projectID] 30 if byWidth == nil || len(byWidth) >= maxCachedReadmeWidths { 31 byWidth = make(map[int]string) 32 m.projectReadmeRender[projectID] = byWidth 33 } 34 35 byWidth[width] = rendered 36 } 37 38 // ensureSelectedProjectMarkdown renders the selected project's README for the 39 // current detail width when it is not cached. It reports whether it rendered. 40 func (m *portfolioModel) ensureSelectedProjectMarkdown() bool { 41 if m.activeTab != tabProjects || m.projectReadmeRender == nil { 42 return false 43 } 44 45 project := m.selectedProject() 46 if project == nil { 47 return false 48 } 49 50 readme, ok := m.projectReadmes[project.ID] 51 if !ok || strings.TrimSpace(readme) == "" { 52 return false 53 } 54 55 width := m.projectMarkdownWidth() 56 if _, ok := m.cachedProjectMarkdown(project.ID, width); ok { 57 return false 58 } 59 60 patched := replaceFirstH1WithSourceLink(readme, project.Name, project.URL) 61 m.setCachedProjectMarkdown(project.ID, width, renderProjectMarkdown(patched, renderTagList(project.Tech), width)) 62 return true 63 } 64 65 func renderProjectMarkdown(markdown string, techRow string, width int) string { 66 rendered := renderMarkdown(markdown, width) 67 if rendered == "" { 68 rendered = markdown 69 } 70 rendered = outdentRenderedBody(rendered) 71 if strings.TrimSpace(techRow) != "" { 72 rendered = insertTechRowBelowRenderedH1(rendered, techRow) 73 } 74 return rendered 75 } 76 77 func insertTechRowBelowRenderedH1(rendered string, techRow string) string { 78 if strings.TrimSpace(techRow) == "" { 79 return rendered 80 } 81 82 lines := strings.Split(rendered, "\n") 83 headIdx := -1 84 for i, line := range lines { 85 plain := strings.TrimSpace(ansiEscapeRE.ReplaceAllString(line, "")) 86 if plain != "" { 87 headIdx = i 88 break 89 } 90 } 91 if headIdx == -1 { 92 if strings.TrimSpace(rendered) == "" { 93 return techRow 94 } 95 return techRow + "\n\n" + rendered 96 } 97 98 insertAt := headingBlockEndIndex(lines, headIdx) 99 100 bodyStart := insertAt 101 for bodyStart < len(lines) && strings.TrimSpace(ansiEscapeRE.ReplaceAllString(lines[bodyStart], "")) == "" { 102 bodyStart++ 103 } 104 105 header := strings.Join(lines[:insertAt], "\n") 106 if bodyStart >= len(lines) { 107 if strings.TrimSpace(header) == "" { 108 return techRow 109 } 110 return header + "\n" + techRow 111 } 112 113 body := strings.Join(lines[bodyStart:], "\n") 114 return header + "\n" + techRow + "\n\n" + body 115 } 116 117 func headingBlockEndIndex(lines []string, headIdx int) int { 118 for i := headIdx + 1; i < len(lines); i++ { 119 plain := strings.TrimSpace(ansiEscapeRE.ReplaceAllString(lines[i], "")) 120 if horizontalRuleLineRE.MatchString(plain) { 121 return i + 1 122 } 123 if plain == "" { 124 return i 125 } 126 } 127 return len(lines) 128 } 129 130 func outdentRenderedBody(rendered string) string { 131 lines := strings.Split(rendered, "\n") 132 if len(lines) < 2 { 133 return rendered 134 } 135 136 headIdx := -1 137 for i, line := range lines { 138 if strings.TrimSpace(line) != "" { 139 headIdx = i 140 break 141 } 142 } 143 if headIdx == -1 || headIdx == len(lines)-1 { 144 return rendered 145 } 146 147 bodyStart := headIdx + 1 148 for bodyStart < len(lines) && strings.TrimSpace(lines[bodyStart]) == "" { 149 bodyStart++ 150 } 151 if bodyStart >= len(lines) { 152 return rendered 153 } 154 155 outdent := 0 156 for _, line := range lines[bodyStart:] { 157 if strings.TrimSpace(line) == "" || !strings.HasPrefix(line, " ") { 158 continue 159 } 160 indent := 0 161 for indent < len(line) && line[indent] == ' ' { 162 indent++ 163 } 164 if outdent == 0 || indent < outdent { 165 outdent = indent 166 } 167 } 168 if outdent == 0 { 169 return rendered 170 } 171 172 for i := bodyStart; i < len(lines); i++ { 173 indent := 0 174 for indent < len(lines[i]) && indent < outdent && lines[i][indent] == ' ' { 175 indent++ 176 } 177 lines[i] = lines[i][indent:] 178 } 179 180 return strings.Join(lines, "\n") 181 }