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