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