1package pages
2
3import (
4 "errors"
5 "fmt"
6 "html"
7 "html/template"
8 "log"
9 "math"
10 "path/filepath"
11 "reflect"
12 "strings"
13 "time"
14
15 "github.com/dustin/go-humanize"
16 "github.com/microcosm-cc/bluemonday"
17 "tangled.sh/tangled.sh/core/appview/filetree"
18 "tangled.sh/tangled.sh/core/appview/pages/markup"
19)
20
21func funcMap() template.FuncMap {
22 return template.FuncMap{
23 "split": func(s string) []string {
24 return strings.Split(s, "\n")
25 },
26 "truncateAt30": func(s string) string {
27 if len(s) <= 30 {
28 return s
29 }
30 return s[:30] + "…"
31 },
32 "splitOn": func(s, sep string) []string {
33 return strings.Split(s, sep)
34 },
35 "add": func(a, b int) int {
36 return a + b
37 },
38 // the absolute state of go templates
39 "add64": func(a, b int64) int64 {
40 return a + b
41 },
42 "sub": func(a, b int) int {
43 return a - b
44 },
45 "cond": func(cond interface{}, a, b string) string {
46 if cond == nil {
47 return b
48 }
49
50 if boolean, ok := cond.(bool); boolean && ok {
51 return a
52 }
53
54 return b
55 },
56 "didOrHandle": func(did, handle string) string {
57 if handle != "" {
58 return fmt.Sprintf("@%s", handle)
59 } else {
60 return did
61 }
62 },
63 "assoc": func(values ...string) ([][]string, error) {
64 if len(values)%2 != 0 {
65 return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments")
66 }
67 pairs := make([][]string, 0)
68 for i := 0; i < len(values); i += 2 {
69 pairs = append(pairs, []string{values[i], values[i+1]})
70 }
71 return pairs, nil
72 },
73 "append": func(s []string, values ...string) []string {
74 s = append(s, values...)
75 return s
76 },
77 "timeFmt": humanize.Time,
78 "longTimeFmt": func(t time.Time) string {
79 return t.Format("2006-01-02 * 3:04 PM")
80 },
81 "shortTimeFmt": func(t time.Time) string {
82 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
83 {time.Second, "now", time.Second},
84 {2 * time.Second, "1s %s", 1},
85 {time.Minute, "%ds %s", time.Second},
86 {2 * time.Minute, "1min %s", 1},
87 {time.Hour, "%dmin %s", time.Minute},
88 {2 * time.Hour, "1hr %s", 1},
89 {humanize.Day, "%dhrs %s", time.Hour},
90 {2 * humanize.Day, "1d %s", 1},
91 {20 * humanize.Day, "%dd %s", humanize.Day},
92 {8 * humanize.Week, "%dw %s", humanize.Week},
93 {humanize.Year, "%dmo %s", humanize.Month},
94 {18 * humanize.Month, "1y %s", 1},
95 {2 * humanize.Year, "2y %s", 1},
96 {humanize.LongTime, "%dy %s", humanize.Year},
97 {math.MaxInt64, "a long while %s", 1},
98 })
99 },
100 "byteFmt": humanize.Bytes,
101 "length": func(slice any) int {
102 v := reflect.ValueOf(slice)
103 if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
104 return v.Len()
105 }
106 return 0
107 },
108 "splitN": func(s, sep string, n int) []string {
109 return strings.SplitN(s, sep, n)
110 },
111 "escapeHtml": func(s string) template.HTML {
112 if s == "" {
113 return template.HTML("<br>")
114 }
115 return template.HTML(s)
116 },
117 "unescapeHtml": func(s string) string {
118 return html.UnescapeString(s)
119 },
120 "nl2br": func(text string) template.HTML {
121 return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1))
122 },
123 "unwrapText": func(text string) string {
124 paragraphs := strings.Split(text, "\n\n")
125
126 for i, p := range paragraphs {
127 lines := strings.Split(p, "\n")
128 paragraphs[i] = strings.Join(lines, " ")
129 }
130
131 return strings.Join(paragraphs, "\n\n")
132 },
133 "sequence": func(n int) []struct{} {
134 return make([]struct{}, n)
135 },
136 // take atmost N items from this slice
137 "take": func(slice any, n int) any {
138 v := reflect.ValueOf(slice)
139 if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
140 return nil
141 }
142 if v.Len() == 0 {
143 return nil
144 }
145 return v.Slice(0, min(n, v.Len()-1)).Interface()
146 },
147
148 "markdown": func(text string) template.HTML {
149 rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault}
150 return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text)))
151 },
152 "isNil": func(t any) bool {
153 // returns false for other "zero" values
154 return t == nil
155 },
156 "list": func(args ...any) []any {
157 return args
158 },
159 "dict": func(values ...any) (map[string]any, error) {
160 if len(values)%2 != 0 {
161 return nil, errors.New("invalid dict call")
162 }
163 dict := make(map[string]any, len(values)/2)
164 for i := 0; i < len(values); i += 2 {
165 key, ok := values[i].(string)
166 if !ok {
167 return nil, errors.New("dict keys must be strings")
168 }
169 dict[key] = values[i+1]
170 }
171 return dict, nil
172 },
173 "i": func(name string, classes ...string) template.HTML {
174 data, err := icon(name, classes)
175 if err != nil {
176 log.Printf("icon %s does not exist", name)
177 data, _ = icon("airplay", classes)
178 }
179 return template.HTML(data)
180 },
181 "cssContentHash": CssContentHash,
182 "fileTree": filetree.FileTree,
183 }
184}
185
186func icon(name string, classes []string) (template.HTML, error) {
187 iconPath := filepath.Join("static", "icons", name)
188
189 if filepath.Ext(name) == "" {
190 iconPath += ".svg"
191 }
192
193 data, err := Files.ReadFile(iconPath)
194 if err != nil {
195 return "", fmt.Errorf("icon %s not found: %w", name, err)
196 }
197
198 // Convert SVG data to string
199 svgStr := string(data)
200
201 svgTagEnd := strings.Index(svgStr, ">")
202 if svgTagEnd == -1 {
203 return "", fmt.Errorf("invalid SVG format for icon %s", name)
204 }
205
206 classTag := ` class="` + strings.Join(classes, " ") + `"`
207
208 modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:]
209 return template.HTML(modifiedSVG), nil
210}