apps/kepler/render.go 6.1 K raw
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
}