| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "html/template" |
| 7 | "io/fs" |
| 8 | "net/http" |
| 9 | "path" |
| 10 | "regexp" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/go-git/go-git/v5/plumbing/object" |
| 15 | "github.com/yuin/goldmark" |
| 16 | "github.com/yuin/goldmark/extension" |
| 17 | "github.com/yuin/goldmark/parser" |
| 18 | gmhtml "github.com/yuin/goldmark/renderer/html" |
| 19 | |
| 20 | "github.com/stevedylandev/andromeda/pkg/web" |
| 21 | ) |
| 22 | |
| 23 | var md = goldmark.New( |
| 24 | goldmark.WithExtensions(extension.Strikethrough, extension.Table, extension.TaskList, extension.Linkify), |
| 25 | goldmark.WithParserOptions(parser.WithAutoHeadingID()), |
| 26 | goldmark.WithRendererOptions(gmhtml.WithUnsafe()), |
| 27 | ) |
| 28 | |
| 29 | func buildTemplates() (map[string]*template.Template, error) { |
| 30 | funcs := template.FuncMap{ |
| 31 | "shortSHA": func(s string) string { |
| 32 | if len(s) >= 8 { |
| 33 | return s[:8] |
| 34 | } |
| 35 | return s |
| 36 | }, |
| 37 | "humanSize": humanSize, |
| 38 | "timeAgo": timeAgo, |
| 39 | } |
| 40 | |
| 41 | pages, err := fs.Glob(appFS, "templates/*.html") |
| 42 | if err != nil { |
| 43 | return nil, err |
| 44 | } |
| 45 | out := make(map[string]*template.Template, len(pages)) |
| 46 | for _, page := range pages { |
| 47 | if strings.HasSuffix(page, "/base.html") { |
| 48 | continue |
| 49 | } |
| 50 | tmpl, err := template.New("").Funcs(funcs).ParseFS(appFS, "templates/base.html", page) |
| 51 | if err != nil { |
| 52 | return nil, fmt.Errorf("parse %s: %w", page, err) |
| 53 | } |
| 54 | out[path.Base(page)] = tmpl |
| 55 | } |
| 56 | return out, nil |
| 57 | } |
| 58 | |
| 59 | func (a *App) renderPage(w http.ResponseWriter, name string, data any) { |
| 60 | tmpl, ok := a.Templates[name] |
| 61 | if !ok { |
| 62 | a.Log.Error("template missing", "name", name) |
| 63 | http.Error(w, "template missing", http.StatusInternalServerError) |
| 64 | return |
| 65 | } |
| 66 | web.Render(tmpl, w, name, data, a.Log) |
| 67 | } |
| 68 | |
| 69 | func renderMarkdown(source, rawBase string) (template.HTML, error) { |
| 70 | var buf bytes.Buffer |
| 71 | if err := md.Convert([]byte(source), &buf); err != nil { |
| 72 | return "", err |
| 73 | } |
| 74 | out := buf.String() |
| 75 | if rawBase != "" { |
| 76 | out = rewriteRelativeImages(out, rawBase) |
| 77 | } |
| 78 | return template.HTML(out), nil |
| 79 | } |
| 80 | |
| 81 | var imgSrcRe = regexp.MustCompile(`(<img\b[^>]*?\bsrc=")([^"]+)(")`) |
| 82 | |
| 83 | func rewriteRelativeImages(html, base string) string { |
| 84 | return imgSrcRe.ReplaceAllStringFunc(html, func(m string) string { |
| 85 | sub := imgSrcRe.FindStringSubmatch(m) |
| 86 | src := sub[2] |
| 87 | if isAbsoluteRef(src) { |
| 88 | return m |
| 89 | } |
| 90 | src = strings.TrimPrefix(src, "./") |
| 91 | return sub[1] + base + src + sub[3] |
| 92 | }) |
| 93 | } |
| 94 | |
| 95 | func isAbsoluteRef(s string) bool { |
| 96 | if s == "" { |
| 97 | return true |
| 98 | } |
| 99 | if strings.HasPrefix(s, "/") || strings.HasPrefix(s, "#") { |
| 100 | return true |
| 101 | } |
| 102 | if strings.HasPrefix(s, "data:") || strings.HasPrefix(s, "//") { |
| 103 | return true |
| 104 | } |
| 105 | return strings.Contains(s, "://") |
| 106 | } |
| 107 | |
| 108 | func renderBlobLines(source string) template.HTML { |
| 109 | source = strings.ReplaceAll(source, "\r\n", "\n") |
| 110 | lines := strings.Split(source, "\n") |
| 111 | if len(lines) > 0 && lines[len(lines)-1] == "" { |
| 112 | lines = lines[:len(lines)-1] |
| 113 | } |
| 114 | var buf bytes.Buffer |
| 115 | buf.WriteString(`<table class="blob-code"><tbody>`) |
| 116 | for i, line := range lines { |
| 117 | n := i + 1 |
| 118 | fmt.Fprintf(&buf, |
| 119 | `<tr id="L%d"><td class="line-num"><a href="#L%d">%d</a></td><td class="line-code"><pre>%s</pre></td></tr>`, |
| 120 | n, n, n, template.HTMLEscapeString(line), |
| 121 | ) |
| 122 | } |
| 123 | buf.WriteString(`</tbody></table>`) |
| 124 | return template.HTML(buf.String()) |
| 125 | } |
| 126 | |
| 127 | func findReadme(tree *object.Tree) (string, bool) { |
| 128 | names := []string{"README.md", "readme.md", "Readme.md", "README", "readme", "README.txt", "readme.txt"} |
| 129 | for _, n := range names { |
| 130 | f, err := tree.File(n) |
| 131 | if err == nil { |
| 132 | content, err := f.Contents() |
| 133 | if err == nil { |
| 134 | return content, true |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | return "", false |
| 139 | } |
| 140 | |
| 141 | func convertPatch(patch *object.Patch) ([]FilePatch, DiffStats) { |
| 142 | var files []FilePatch |
| 143 | stats := DiffStats{} |
| 144 | for _, fp := range patch.FilePatches() { |
| 145 | from, to := fp.Files() |
| 146 | fromName, toName := "", "" |
| 147 | if from != nil { |
| 148 | fromName = from.Path() |
| 149 | } |
| 150 | if to != nil { |
| 151 | toName = to.Path() |
| 152 | } |
| 153 | out := FilePatch{From: fromName, To: toName, IsBin: fp.IsBinary()} |
| 154 | var all []DiffLine |
| 155 | oldNum, newNum := 1, 1 |
| 156 | for _, ch := range fp.Chunks() { |
| 157 | kind := "" |
| 158 | switch ch.Type() { |
| 159 | case 1: // Add |
| 160 | kind = "add" |
| 161 | case 2: // Delete |
| 162 | kind = "del" |
| 163 | default: |
| 164 | kind = "ctx" |
| 165 | } |
| 166 | text := ch.Content() |
| 167 | lines := strings.Split(text, "\n") |
| 168 | if len(lines) > 0 && lines[len(lines)-1] == "" { |
| 169 | lines = lines[:len(lines)-1] |
| 170 | } |
| 171 | for _, l := range lines { |
| 172 | dl := DiffLine{Kind: kind, Text: l} |
| 173 | switch kind { |
| 174 | case "add": |
| 175 | dl.NewNum = newNum |
| 176 | newNum++ |
| 177 | out.Added++ |
| 178 | case "del": |
| 179 | dl.OldNum = oldNum |
| 180 | oldNum++ |
| 181 | out.Removed++ |
| 182 | default: |
| 183 | dl.OldNum = oldNum |
| 184 | dl.NewNum = newNum |
| 185 | oldNum++ |
| 186 | newNum++ |
| 187 | } |
| 188 | all = append(all, dl) |
| 189 | } |
| 190 | } |
| 191 | out.Hunks = collapseContext(all, diffContextLines) |
| 192 | stats.Files++ |
| 193 | stats.Added += out.Added |
| 194 | stats.Removed += out.Removed |
| 195 | files = append(files, out) |
| 196 | } |
| 197 | return files, stats |
| 198 | } |
| 199 | |
| 200 | const diffContextLines = 3 |
| 201 | |
| 202 | func collapseContext(lines []DiffLine, ctx int) []DiffHunk { |
| 203 | if len(lines) == 0 { |
| 204 | return nil |
| 205 | } |
| 206 | keep := make([]bool, len(lines)) |
| 207 | for i, l := range lines { |
| 208 | if l.Kind == "add" || l.Kind == "del" { |
| 209 | start := i - ctx |
| 210 | if start < 0 { |
| 211 | start = 0 |
| 212 | } |
| 213 | end := i + ctx |
| 214 | if end >= len(lines) { |
| 215 | end = len(lines) - 1 |
| 216 | } |
| 217 | for j := start; j <= end; j++ { |
| 218 | keep[j] = true |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | var hunks []DiffHunk |
| 223 | var current *DiffHunk |
| 224 | for i, l := range lines { |
| 225 | if keep[i] { |
| 226 | if current == nil { |
| 227 | current = &DiffHunk{} |
| 228 | } |
| 229 | current.Lines = append(current.Lines, l) |
| 230 | } else if current != nil { |
| 231 | hunks = append(hunks, *current) |
| 232 | current = nil |
| 233 | } |
| 234 | } |
| 235 | if current != nil { |
| 236 | hunks = append(hunks, *current) |
| 237 | } |
| 238 | return hunks |
| 239 | } |
| 240 | |
| 241 | func humanSize(n int64) string { |
| 242 | const k = 1024 |
| 243 | if n < k { |
| 244 | return fmt.Sprintf("%d B", n) |
| 245 | } |
| 246 | div, exp := int64(k), 0 |
| 247 | for n2 := n / k; n2 >= k; n2 /= k { |
| 248 | div *= k |
| 249 | exp++ |
| 250 | } |
| 251 | units := []string{"K", "M", "G", "T"} |
| 252 | return fmt.Sprintf("%.1f %s", float64(n)/float64(div), units[exp]) |
| 253 | } |
| 254 | |
| 255 | func timeAgo(t time.Time) string { |
| 256 | if t.IsZero() { |
| 257 | return "" |
| 258 | } |
| 259 | d := time.Since(t) |
| 260 | switch { |
| 261 | case d < time.Minute: |
| 262 | return fmt.Sprintf("%ds ago", int(d.Seconds())) |
| 263 | case d < time.Hour: |
| 264 | return fmt.Sprintf("%dm ago", int(d.Minutes())) |
| 265 | case d < 24*time.Hour: |
| 266 | return fmt.Sprintf("%dh ago", int(d.Hours())) |
| 267 | case d < 30*24*time.Hour: |
| 268 | return fmt.Sprintf("%dd ago", int(d.Hours()/24)) |
| 269 | default: |
| 270 | return t.Format("2006-01-02") |
| 271 | } |
| 272 | } |