template.go 8.9 KiB raw
1
package main
2
3
import (
4
	"embed"
5
	"fmt"
6
	"html"
7
	"html/template"
8
	"io"
9
	"path"
10
	"regexp"
11
	"strings"
12
	"time"
13
)
14
15
//go:embed templates/*.html
16
var templateFS embed.FS
17
18
//go:embed static/style.css
19
var cssContent []byte
20
21
//go:embed static/radiant.svg
22
var logoContent []byte
23
24
//go:embed static/fonts/*
25
var fontsFS embed.FS
26
27
//go:embed static/avatars/*
28
var avatarsFS embed.FS
29
30
//go:embed static/js/hirad.js
31
var hiradJS []byte
32
33
//go:embed static/js/hiril.js
34
var hirilJS []byte
35
36
type templateSet struct {
37
	templates map[string]*template.Template
38
}
39
40
var funcMap = template.FuncMap{
41
	"shortHash": func(h string) string {
42
		if len(h) > 8 {
43
			return h[:8]
44
		}
45
		return h
46
	},
47
	"timeAgo": func(t time.Time) template.HTML {
48
		d := time.Since(t)
49
		var ago string
50
		switch {
51
		case d < time.Minute:
52
			ago = "just now"
53
		case d < time.Hour:
54
			m := int(d.Minutes())
55
			if m == 1 {
56
				ago = "1 minute"
57
			} else {
58
				ago = fmt.Sprintf("%d minutes", m)
59
			}
60
		case d < 24*time.Hour:
61
			h := int(d.Hours())
62
			if h == 1 {
63
				ago = "1 hour"
64
			} else {
65
				ago = fmt.Sprintf("%d hours", h)
66
			}
67
		case d < 30*24*time.Hour:
68
			days := int(d.Hours() / 24)
69
			if days == 1 {
70
				ago = "1 day"
71
			} else {
72
				ago = fmt.Sprintf("%d days", days)
73
			}
74
		case d < 365*24*time.Hour:
75
			months := int(d.Hours() / 24 / 30)
76
			if months == 1 {
77
				ago = "1 month"
78
			} else {
79
				ago = fmt.Sprintf("%d months", months)
80
			}
81
		default:
82
			years := int(d.Hours() / 24 / 365)
83
			if years == 1 {
84
				ago = "1 year"
85
			} else {
86
				ago = fmt.Sprintf("%d years", years)
87
			}
88
		}
89
		full := t.Format("2006-01-02 15:04:05 -0700")
90
		return template.HTML(fmt.Sprintf(`<time title="%s">%s</time>`, full, ago))
91
	},
92
	"formatDate": func(t time.Time) string {
93
		return t.Format("2006-01-02 15:04:05 -0700")
94
	},
95
	"add": func(a, b int) int { return a + b },
96
	"diffFileName": func(f DiffFile) string {
97
		if f.NewName != "" {
98
			return f.NewName
99
		}
100
		return f.OldName
101
	},
102
	"statusLabel": func(s string) string {
103
		switch s {
104
		case "A":
105
			return "added"
106
		case "D":
107
			return "deleted"
108
		case "M":
109
			return "modified"
110
		case "R":
111
			return "renamed"
112
		}
113
		return s
114
	},
115
	"formatSize": func(size int64) string {
116
		switch {
117
		case size < 1024:
118
			return fmt.Sprintf("%d B", size)
119
		case size < 1024*1024:
120
			return fmt.Sprintf("%.1f KiB", float64(size)/1024)
121
		default:
122
			return fmt.Sprintf("%.1f MiB", float64(size)/(1024*1024))
123
		}
124
	},
125
	"diffBar": func(added, deleted int) template.HTML {
126
		total := added + deleted
127
		if total == 0 {
128
			return ""
129
		}
130
		const maxBlocks = 5
131
		blocks := maxBlocks
132
		if total < blocks {
133
			blocks = total
134
		}
135
		addBlocks := 0
136
		if total > 0 {
137
			addBlocks = (added*blocks + total - 1) / total
138
		}
139
		if addBlocks > blocks {
140
			addBlocks = blocks
141
		}
142
		delBlocks := blocks - addBlocks
143
		var b strings.Builder
144
		b.WriteString(`<span class="diffstat-bar">`)
145
		for i := 0; i < addBlocks; i++ {
146
			b.WriteString(`<span class="bar-add"></span>`)
147
		}
148
		for i := 0; i < delBlocks; i++ {
149
			b.WriteString(`<span class="bar-del"></span>`)
150
		}
151
		for i := addBlocks + delBlocks; i < maxBlocks; i++ {
152
			b.WriteString(`<span class="bar-neutral"></span>`)
153
		}
154
		b.WriteString(`</span>`)
155
		return template.HTML(b.String())
156
	},
157
	"langClass": func(name string) string {
158
		ext := path.Ext(name)
159
		switch ext {
160
		case ".rad":
161
			return "language-radiance"
162
		case ".ril":
163
			return "language-ril"
164
		}
165
		return ""
166
	},
167
	"parentPath": func(p string) string {
168
		dir := path.Dir(p)
169
		if dir == "." {
170
			return ""
171
		}
172
		return dir
173
	},
174
	"indent": func(depth int) string {
175
		if depth == 0 {
176
			return ""
177
		}
178
		return fmt.Sprintf("padding-left: %grem", float64(depth)*1.2)
179
	},
180
	"formatBody": func(s string) template.HTML {
181
		s = strings.TrimSpace(s)
182
		if s == "" {
183
			return ""
184
		}
185
		var b strings.Builder
186
		// Split into alternating prose / fenced-code-block segments.
187
		for s != "" {
188
			// Find the next opening fence.
189
			openIdx := strings.Index(s, "```")
190
			if openIdx < 0 {
191
				formatParagraphs(&b, s)
192
				break
193
			}
194
			// Prose before the fence.
195
			if openIdx > 0 {
196
				formatParagraphs(&b, s[:openIdx])
197
			}
198
			// Skip the opening ``` and any language tag on the same line.
199
			rest := s[openIdx+3:]
200
			if nl := strings.Index(rest, "\n"); nl >= 0 {
201
				rest = rest[nl+1:]
202
			} else {
203
				// ``` at end of input with nothing after — treat as prose.
204
				formatParagraphs(&b, s[openIdx:])
205
				break
206
			}
207
			// Find the closing fence.
208
			closeIdx := strings.Index(rest, "```")
209
			var code string
210
			if closeIdx < 0 {
211
				code = rest
212
				s = ""
213
			} else {
214
				code = rest[:closeIdx]
215
				s = rest[closeIdx+3:]
216
			}
217
			code = strings.TrimRight(code, "\n")
218
			b.WriteString("<pre><code>")
219
			b.WriteString(html.EscapeString(code))
220
			b.WriteString("</code></pre>")
221
		}
222
		return template.HTML(b.String())
223
	},
224
	"discussionPath": func(baseURL, repo string) string {
225
		if repo == "" {
226
			return baseURL + "/discussions"
227
		}
228
		return baseURL + "/" + repo + "/discussions"
229
	},
230
	"autolink": func(s string) template.HTML {
231
		var b strings.Builder
232
		last := 0
233
		for _, loc := range urlRe.FindAllStringIndex(s, -1) {
234
			// Strip trailing punctuation.
235
			end := loc[1]
236
			for end > loc[0] && strings.ContainsRune(".,;:!?)]", rune(s[end-1])) {
237
				end--
238
			}
239
			b.WriteString(html.EscapeString(s[last:loc[0]]))
240
			u := s[loc[0]:end]
241
			b.WriteString(`<a href="`)
242
			b.WriteString(html.EscapeString(u))
243
			b.WriteString(`">`)
244
			b.WriteString(html.EscapeString(u))
245
			b.WriteString(`</a>`)
246
			last = end
247
		}
248
		b.WriteString(html.EscapeString(s[last:]))
249
		return template.HTML(b.String())
250
	},
251
}
252
253
var urlRe = regexp.MustCompile(`https?://[^\s<>"'` + "`" + `\x00-\x1f]+`)
254
255
// inlineRe matches (in order): backtick code, angle-bracket links, bare URLs, or *italic*.
256
var inlineRe = regexp.MustCompile("`" + `([^` + "`" + `\n]+)` + "`" +
257
	`|<(https?://[^\s<>]+)>` +
258
	`|(https?://[^\s<>"'` + "`" + `\x00-\x1f]+)` +
259
	`|\*([^\s*][^*]*[^\s*])\*|\*([^\s*])\*`)
260
261
// formatParagraphs splits prose text on blank lines and writes <p> elements
262
// with inline formatting into b.
263
func formatParagraphs(b *strings.Builder, s string) {
264
	for _, p := range strings.Split(s, "\n\n") {
265
		p = strings.TrimSpace(p)
266
		if p == "" {
267
			continue
268
		}
269
		b.WriteString("<p>")
270
		b.WriteString(formatInline(p))
271
		b.WriteString("</p>")
272
	}
273
}
274
275
// formatInline applies inline formatting to a paragraph of raw text.
276
// It handles backtick `code`, *italic*, <https://...> links, and bare URLs.
277
func formatInline(p string) string {
278
	var b strings.Builder
279
	last := 0
280
	for _, m := range inlineRe.FindAllStringSubmatchIndex(p, -1) {
281
		start, end := m[0], m[1]
282
		b.WriteString(html.EscapeString(p[last:start]))
283
284
		switch {
285
		case m[2] >= 0: // backtick code: group 1
286
			code := p[m[2]:m[3]]
287
			b.WriteString("<code>")
288
			b.WriteString(html.EscapeString(code))
289
			b.WriteString("</code>")
290
		case m[4] >= 0: // angle-bracket link: group 2
291
			u := p[m[4]:m[5]]
292
			b.WriteString(`<a href="`)
293
			b.WriteString(html.EscapeString(u))
294
			b.WriteString(`">`)
295
			b.WriteString(html.EscapeString(u))
296
			b.WriteString("</a>")
297
		case m[6] >= 0: // bare URL: group 3
298
			// Strip trailing punctuation.
299
			urlEnd := m[7]
300
			for urlEnd > m[6] && strings.ContainsRune(".,;:!?)]", rune(p[urlEnd-1])) {
301
				urlEnd--
302
			}
303
			u := p[m[6]:urlEnd]
304
			b.WriteString(`<a href="`)
305
			b.WriteString(html.EscapeString(u))
306
			b.WriteString(`">`)
307
			b.WriteString(html.EscapeString(u))
308
			b.WriteString("</a>")
309
			// Anything between stripped punctuation and regex end is plain text.
310
			b.WriteString(html.EscapeString(p[urlEnd:end]))
311
		case m[8] >= 0: // italic (multi-char): group 4
312
			inner := p[m[8]:m[9]]
313
			b.WriteString("<em>")
314
			b.WriteString(html.EscapeString(inner))
315
			b.WriteString("</em>")
316
		case m[10] >= 0: // italic (single-char): group 5
317
			inner := p[m[10]:m[11]]
318
			b.WriteString("<em>")
319
			b.WriteString(html.EscapeString(inner))
320
			b.WriteString("</em>")
321
		}
322
		last = end
323
	}
324
	b.WriteString(html.EscapeString(p[last:]))
325
	return b.String()
326
}
327
328
func loadTemplates() (*templateSet, error) {
329
	layoutContent, err := templateFS.ReadFile("templates/layout.html")
330
	if err != nil {
331
		return nil, fmt.Errorf("read layout: %w", err)
332
	}
333
334
	pages := []string{"index", "home", "log", "commit", "refs", "error", "discussions", "discussion", "discussion_new", "login"}
335
	ts := &templateSet{
336
		templates: make(map[string]*template.Template, len(pages)),
337
	}
338
339
	for _, page := range pages {
340
		t := template.New("layout").Funcs(funcMap)
341
		t, err = t.Parse(string(layoutContent))
342
		if err != nil {
343
			return nil, fmt.Errorf("parse layout: %w", err)
344
		}
345
346
		pageContent, err := templateFS.ReadFile(fmt.Sprintf("templates/%s.html", page))
347
		if err != nil {
348
			return nil, fmt.Errorf("read %s: %w", page, err)
349
		}
350
351
		t, err = t.Parse(string(pageContent))
352
		if err != nil {
353
			return nil, fmt.Errorf("parse %s: %w", page, err)
354
		}
355
356
		ts.templates[page] = t
357
	}
358
359
	return ts, nil
360
}
361
362
func (ts *templateSet) render(w io.Writer, name string, data any) {
363
	t, ok := ts.templates[name]
364
	if !ok {
365
		fmt.Fprintf(w, "template %q not found", name)
366
		return
367
	}
368
	if err := t.Execute(w, data); err != nil {
369
		fmt.Fprintf(w, "template error: %v", err)
370
	}
371
}