1package pages
2
3import (
4 "bytes"
5 "context"
6 "crypto/hmac"
7 "crypto/sha256"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "html"
12 "html/template"
13 "log"
14 "math"
15 "net/url"
16 "path/filepath"
17 "reflect"
18 "strings"
19 "time"
20
21 "github.com/alecthomas/chroma/v2"
22 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
23 "github.com/alecthomas/chroma/v2/lexers"
24 "github.com/alecthomas/chroma/v2/styles"
25 "github.com/dustin/go-humanize"
26 "github.com/go-enry/go-enry/v2"
27 "github.com/yuin/goldmark"
28 "tangled.org/core/appview/filetree"
29 "tangled.org/core/appview/models"
30 "tangled.org/core/appview/pages/markup"
31 "tangled.org/core/crypto"
32)
33
34func (p *Pages) funcMap() template.FuncMap {
35 return template.FuncMap{
36 "split": func(s string) []string {
37 return strings.Split(s, "\n")
38 },
39 "trimPrefix": func(s, prefix string) string {
40 return strings.TrimPrefix(s, prefix)
41 },
42 "join": func(elems []string, sep string) string {
43 return strings.Join(elems, sep)
44 },
45 "contains": func(s string, target string) bool {
46 return strings.Contains(s, target)
47 },
48 "stripPort": func(hostname string) string {
49 if strings.Contains(hostname, ":") {
50 return strings.Split(hostname, ":")[0]
51 }
52 return hostname
53 },
54 "mapContains": func(m any, key any) bool {
55 mapValue := reflect.ValueOf(m)
56 if mapValue.Kind() != reflect.Map {
57 return false
58 }
59 keyValue := reflect.ValueOf(key)
60 return mapValue.MapIndex(keyValue).IsValid()
61 },
62 "resolve": func(s string) string {
63 identity, err := p.resolver.ResolveIdent(context.Background(), s)
64
65 if err != nil {
66 return s
67 }
68
69 if identity.Handle.IsInvalidHandle() {
70 return "handle.invalid"
71 }
72
73 return identity.Handle.String()
74 },
75 "ownerSlashRepo": func(repo *models.Repo) string {
76 ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did)
77 if err != nil {
78 return repo.DidSlashRepo()
79 }
80 handle := ownerId.Handle
81 if handle != "" && !handle.IsInvalidHandle() {
82 return string(handle) + "/" + repo.Name
83 }
84 return repo.DidSlashRepo()
85 },
86 "truncateAt30": func(s string) string {
87 if len(s) <= 30 {
88 return s
89 }
90 return s[:30] + "…"
91 },
92 "splitOn": func(s, sep string) []string {
93 return strings.Split(s, sep)
94 },
95 "string": func(v any) string {
96 return fmt.Sprint(v)
97 },
98 "int64": func(a int) int64 {
99 return int64(a)
100 },
101 "add": func(a, b int) int {
102 return a + b
103 },
104 "now": func() time.Time {
105 return time.Now()
106 },
107 // the absolute state of go templates
108 "add64": func(a, b int64) int64 {
109 return a + b
110 },
111 "sub": func(a, b int) int {
112 return a - b
113 },
114 "mul": func(a, b int) int {
115 return a * b
116 },
117 "div": func(a, b int) int {
118 return a / b
119 },
120 "mod": func(a, b int) int {
121 return a % b
122 },
123 "f64": func(a int) float64 {
124 return float64(a)
125 },
126 "addf64": func(a, b float64) float64 {
127 return a + b
128 },
129 "subf64": func(a, b float64) float64 {
130 return a - b
131 },
132 "mulf64": func(a, b float64) float64 {
133 return a * b
134 },
135 "divf64": func(a, b float64) float64 {
136 if b == 0 {
137 return 0
138 }
139 return a / b
140 },
141 "negf64": func(a float64) float64 {
142 return -a
143 },
144 "cond": func(cond any, a, b string) string {
145 if cond == nil {
146 return b
147 }
148
149 if boolean, ok := cond.(bool); boolean && ok {
150 return a
151 }
152
153 return b
154 },
155 "assoc": func(values ...string) ([][]string, error) {
156 if len(values)%2 != 0 {
157 return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments")
158 }
159 pairs := make([][]string, 0)
160 for i := 0; i < len(values); i += 2 {
161 pairs = append(pairs, []string{values[i], values[i+1]})
162 }
163 return pairs, nil
164 },
165 "append": func(s []any, values ...any) []any {
166 s = append(s, values...)
167 return s
168 },
169 "commaFmt": humanize.Comma,
170 "relTimeFmt": humanize.Time,
171 "shortRelTimeFmt": func(t time.Time) string {
172 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
173 {D: time.Second, Format: "now", DivBy: time.Second},
174 {D: 2 * time.Second, Format: "1s %s", DivBy: 1},
175 {D: time.Minute, Format: "%ds %s", DivBy: time.Second},
176 {D: 2 * time.Minute, Format: "1min %s", DivBy: 1},
177 {D: time.Hour, Format: "%dmin %s", DivBy: time.Minute},
178 {D: 2 * time.Hour, Format: "1hr %s", DivBy: 1},
179 {D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour},
180 {D: 2 * humanize.Day, Format: "1d %s", DivBy: 1},
181 {D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day},
182 {D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week},
183 {D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month},
184 {D: 18 * humanize.Month, Format: "1y %s", DivBy: 1},
185 {D: 2 * humanize.Year, Format: "2y %s", DivBy: 1},
186 {D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year},
187 {D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
188 })
189 },
190 "longTimeFmt": func(t time.Time) string {
191 return t.Format("Jan 2, 2006, 3:04 PM MST")
192 },
193 "iso8601DateTimeFmt": func(t time.Time) string {
194 return t.Format("2006-01-02T15:04:05-07:00")
195 },
196 "iso8601DurationFmt": func(duration time.Duration) string {
197 days := int64(duration.Hours() / 24)
198 hours := int64(math.Mod(duration.Hours(), 24))
199 minutes := int64(math.Mod(duration.Minutes(), 60))
200 seconds := int64(math.Mod(duration.Seconds(), 60))
201 return fmt.Sprintf("P%dD%dH%dM%dS", days, hours, minutes, seconds)
202 },
203 "durationFmt": func(duration time.Duration) string {
204 return durationFmt(duration, [4]string{"d", "hr", "min", "s"})
205 },
206 "longDurationFmt": func(duration time.Duration) string {
207 return durationFmt(duration, [4]string{"days", "hours", "minutes", "seconds"})
208 },
209 "byteFmt": humanize.Bytes,
210 "length": func(slice any) int {
211 v := reflect.ValueOf(slice)
212 if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
213 return v.Len()
214 }
215 return 0
216 },
217 "splitN": func(s, sep string, n int) []string {
218 return strings.SplitN(s, sep, n)
219 },
220 "escapeHtml": func(s string) template.HTML {
221 if s == "" {
222 return template.HTML("<br>")
223 }
224 return template.HTML(s)
225 },
226 "unescapeHtml": func(s string) string {
227 return html.UnescapeString(s)
228 },
229 "nl2br": func(text string) template.HTML {
230 return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
231 },
232 "unwrapText": func(text string) string {
233 paragraphs := strings.Split(text, "\n\n")
234
235 for i, p := range paragraphs {
236 lines := strings.Split(p, "\n")
237 paragraphs[i] = strings.Join(lines, " ")
238 }
239
240 return strings.Join(paragraphs, "\n\n")
241 },
242 "sequence": func(n int) []struct{} {
243 return make([]struct{}, n)
244 },
245 // take atmost N items from this slice
246 "take": func(slice any, n int) any {
247 v := reflect.ValueOf(slice)
248 if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
249 return nil
250 }
251 if v.Len() == 0 {
252 return nil
253 }
254 return v.Slice(0, min(n, v.Len())).Interface()
255 },
256 "markdown": func(text string) template.HTML {
257 p.rctx.RendererType = markup.RendererTypeDefault
258 htmlString := p.rctx.RenderMarkdown(text)
259 sanitized := p.rctx.SanitizeDefault(htmlString)
260 return template.HTML(sanitized)
261 },
262 "description": func(text string) template.HTML {
263 p.rctx.RendererType = markup.RendererTypeDefault
264 htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New())
265 sanitized := p.rctx.SanitizeDescription(htmlString)
266 return template.HTML(sanitized)
267 },
268 "readme": func(text string) template.HTML {
269 p.rctx.RendererType = markup.RendererTypeRepoMarkdown
270 htmlString := p.rctx.RenderMarkdown(text)
271 sanitized := p.rctx.SanitizeDefault(htmlString)
272 return template.HTML(sanitized)
273 },
274 "code": func(content, path string) string {
275 var style *chroma.Style = styles.Get("catpuccin-latte")
276 formatter := chromahtml.New(
277 chromahtml.InlineCode(false),
278 chromahtml.WithLineNumbers(true),
279 chromahtml.WithLinkableLineNumbers(true, "L"),
280 chromahtml.Standalone(false),
281 chromahtml.WithClasses(true),
282 )
283
284 lexer := lexers.Get(filepath.Base(path))
285 if lexer == nil {
286 lexer = lexers.Fallback
287 }
288
289 iterator, err := lexer.Tokenise(nil, content)
290 if err != nil {
291 p.logger.Error("chroma tokenize", "err", "err")
292 return ""
293 }
294
295 var code bytes.Buffer
296 err = formatter.Format(&code, style, iterator)
297 if err != nil {
298 p.logger.Error("chroma format", "err", "err")
299 return ""
300 }
301
302 return code.String()
303 },
304 "trimUriScheme": func(text string) string {
305 text = strings.TrimPrefix(text, "https://")
306 text = strings.TrimPrefix(text, "http://")
307 return text
308 },
309 "isNil": func(t any) bool {
310 // returns false for other "zero" values
311 return t == nil
312 },
313 "list": func(args ...any) []any {
314 return args
315 },
316 "dict": func(values ...any) (map[string]any, error) {
317 if len(values)%2 != 0 {
318 return nil, errors.New("invalid dict call")
319 }
320 dict := make(map[string]any, len(values)/2)
321 for i := 0; i < len(values); i += 2 {
322 key, ok := values[i].(string)
323 if !ok {
324 return nil, errors.New("dict keys must be strings")
325 }
326 dict[key] = values[i+1]
327 }
328 return dict, nil
329 },
330 "deref": func(v any) any {
331 val := reflect.ValueOf(v)
332 if val.Kind() == reflect.Ptr && !val.IsNil() {
333 return val.Elem().Interface()
334 }
335 return nil
336 },
337 "i": func(name string, classes ...string) template.HTML {
338 data, err := p.icon(name, classes)
339 if err != nil {
340 log.Printf("icon %s does not exist", name)
341 data, _ = p.icon("airplay", classes)
342 }
343 return template.HTML(data)
344 },
345 "cssContentHash": p.CssContentHash,
346 "fileTree": filetree.FileTree,
347 "pathEscape": func(s string) string {
348 return url.PathEscape(s)
349 },
350 "pathUnescape": func(s string) string {
351 u, _ := url.PathUnescape(s)
352 return u
353 },
354 "safeUrl": func(s string) template.URL {
355 return template.URL(s)
356 },
357 "tinyAvatar": func(handle string) string {
358 return p.AvatarUrl(handle, "tiny")
359 },
360 "fullAvatar": func(handle string) string {
361 return p.AvatarUrl(handle, "")
362 },
363 "langColor": enry.GetColor,
364 "layoutSide": func() string {
365 return "col-span-1 md:col-span-2 lg:col-span-3"
366 },
367 "layoutCenter": func() string {
368 return "col-span-1 md:col-span-8 lg:col-span-6"
369 },
370
371 "normalizeForHtmlId": func(s string) string {
372 normalized := strings.ReplaceAll(s, ":", "_")
373 normalized = strings.ReplaceAll(normalized, ".", "_")
374 return normalized
375 },
376 "sshFingerprint": func(pubKey string) string {
377 fp, err := crypto.SSHFingerprint(pubKey)
378 if err != nil {
379 return "error"
380 }
381 return fp
382 },
383 }
384}
385
386func (p *Pages) resolveDid(did string) string {
387 identity, err := p.resolver.ResolveIdent(context.Background(), did)
388
389 if err != nil {
390 return did
391 }
392
393 if identity.Handle.IsInvalidHandle() {
394 return "handle.invalid"
395 }
396
397 return identity.Handle.String()
398}
399
400func (p *Pages) AvatarUrl(handle, size string) string {
401 handle = strings.TrimPrefix(handle, "@")
402
403 handle = p.resolveDid(handle)
404
405 secret := p.avatar.SharedSecret
406 h := hmac.New(sha256.New, []byte(secret))
407 h.Write([]byte(handle))
408 signature := hex.EncodeToString(h.Sum(nil))
409
410 sizeArg := ""
411 if size != "" {
412 sizeArg = fmt.Sprintf("size=%s", size)
413 }
414 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg)
415}
416
417func (p *Pages) icon(name string, classes []string) (template.HTML, error) {
418 iconPath := filepath.Join("static", "icons", name)
419
420 if filepath.Ext(name) == "" {
421 iconPath += ".svg"
422 }
423
424 data, err := Files.ReadFile(iconPath)
425 if err != nil {
426 return "", fmt.Errorf("icon %s not found: %w", name, err)
427 }
428
429 // Convert SVG data to string
430 svgStr := string(data)
431
432 svgTagEnd := strings.Index(svgStr, ">")
433 if svgTagEnd == -1 {
434 return "", fmt.Errorf("invalid SVG format for icon %s", name)
435 }
436
437 classTag := ` class="` + strings.Join(classes, " ") + `"`
438
439 modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:]
440 return template.HTML(modifiedSVG), nil
441}
442
443func durationFmt(duration time.Duration, names [4]string) string {
444 days := int64(duration.Hours() / 24)
445 hours := int64(math.Mod(duration.Hours(), 24))
446 minutes := int64(math.Mod(duration.Minutes(), 60))
447 seconds := int64(math.Mod(duration.Seconds(), 60))
448
449 chunks := []struct {
450 name string
451 amount int64
452 }{
453 {names[0], days},
454 {names[1], hours},
455 {names[2], minutes},
456 {names[3], seconds},
457 }
458
459 parts := []string{}
460
461 for _, chunk := range chunks {
462 if chunk.amount != 0 {
463 parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name))
464 }
465 }
466
467 return strings.Join(parts, " ")
468}