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