github.com/brandur/modulir@v0.0.0-20240305213423-94ee82929cbd/modules/mtemplate/mtemplate.go (about) 1 package mtemplate 2 3 import ( 4 "context" 5 "fmt" 6 "html/template" 7 "math" 8 "net/url" 9 "path/filepath" 10 "regexp" 11 "sort" 12 "strconv" 13 "strings" 14 texttemplate "text/template" 15 "time" 16 17 "golang.org/x/xerrors" 18 ) 19 20 ////////////////////////////////////////////////////////////////////////////// 21 // 22 // 23 // 24 // Public 25 // 26 // 27 // 28 ////////////////////////////////////////////////////////////////////////////// 29 30 // FuncMap is a set of helper functions to make available in templates for the 31 // project. 32 var FuncMap = template.FuncMap{ 33 "CollapseParagraphs": CollapseParagraphs, 34 "DistanceOfTimeInWords": DistanceOfTimeInWords, 35 "DistanceOfTimeInWordsFromNow": DistanceOfTimeInWordsFromNow, 36 "DownloadedImage": DownloadedImage, 37 "Figure": Figure, 38 "FigureSingle": FigureSingle, 39 "FigureSingleWithClass": FigureSingleWithClass, 40 "FormatTime": FormatTime, 41 "FormatTimeRFC3339UTC": FormatTimeRFC3339UTC, 42 "FormatTimeSimpleDate": FormatTimeSimpleDate, 43 "HTMLRender": HTMLRender, 44 "HTMLSafePassThrough": HTMLSafePassThrough, 45 "ImgSrcAndAlt": ImgSrcAndAlt, 46 "ImgSrcAndAltAndClass": ImgSrcAndAltAndClass, 47 "Map": Map, 48 "MapVal": MapVal, 49 "MapValAdd": MapValAdd, 50 "QueryEscape": QueryEscape, 51 "RomanNumeral": RomanNumeral, 52 "RoundToString": RoundToString, 53 "TimeIn": TimeIn, 54 "To2X": To2X, 55 } 56 57 // CollapseParagraphs strips paragraph tags out of rendered HTML. Note that it 58 // does not handle HTML with any attributes, so is targeted mainly for use with 59 // HTML generated from Markdown. 60 func CollapseParagraphs(s string) string { 61 sCollapsed := s 62 sCollapsed = strings.ReplaceAll(sCollapsed, "<p>", "") 63 sCollapsed = strings.ReplaceAll(sCollapsed, "</p>", "") 64 return collapseHTML(sCollapsed) 65 } 66 67 // CombineFuncMaps combines a number of function maps into one. The combined 68 // version is a new function map so that none of the originals are tainted. 69 func CombineFuncMaps(funcMaps ...template.FuncMap) template.FuncMap { 70 // Combine both sets of helpers into a single untainted function map. 71 combined := make(template.FuncMap) 72 73 for _, fm := range funcMaps { 74 for k, v := range fm { 75 if _, ok := combined[k]; ok { 76 panic(xerrors.Errorf("duplicate function map key on combine: %s", k)) 77 } 78 79 combined[k] = v 80 } 81 } 82 83 return combined 84 } 85 86 // HTMLFuncMapToText transforms an HTML func map to a text func map. 87 func HTMLFuncMapToText(funcMap template.FuncMap) texttemplate.FuncMap { 88 textFuncMap := make(texttemplate.FuncMap) 89 90 for k, v := range funcMap { 91 textFuncMap[k] = v 92 } 93 94 return textFuncMap 95 } 96 97 const ( 98 minutesInDay = 24 * 60 99 minutesInMonth = 30 * 24 * 60 100 minutesInYear = 365 * 24 * 60 101 ) 102 103 // DistanceOfTimeInWords returns a string describing the relative time passed 104 // between two times. 105 func DistanceOfTimeInWords(to, from time.Time) string { 106 d := from.Sub(to) 107 108 min := int(round(d.Minutes())) 109 110 switch { 111 case min == 0: 112 return "less than 1 minute" 113 case min == 1: 114 return fmt.Sprintf("%d minute", min) 115 case min >= 1 && min <= 44: 116 return fmt.Sprintf("%d minutes", min) 117 case min >= 45 && min <= 89: 118 return "about 1 hour" 119 case min >= 90 && min <= minutesInDay-1: 120 return fmt.Sprintf("about %d hours", int(round(d.Hours()))) 121 case min >= minutesInDay && min <= minutesInDay*2-1: 122 return "about 1 day" 123 case min >= 2520 && min <= minutesInMonth-1: 124 return fmt.Sprintf("%d days", int(round(d.Hours()/24.0))) 125 case min >= minutesInMonth && min <= minutesInMonth*2-1: 126 return "about 1 month" 127 case min >= minutesInMonth*2 && min <= minutesInYear-1: 128 return fmt.Sprintf("%d months", int(round(d.Hours()/24.0/30.0))) 129 case min >= minutesInYear && min <= minutesInYear+3*minutesInMonth-1: 130 return "about 1 year" 131 case min >= minutesInYear+3*minutesInMonth-1 && min <= minutesInYear+9*minutesInMonth-1: 132 return "over 1 year" 133 case min >= minutesInYear+9*minutesInMonth && min <= minutesInYear*2-1: 134 return "almost 2 years" 135 } 136 137 return fmt.Sprintf("%d years", int(round(d.Hours()/24.0/365.0))) 138 } 139 140 // DistanceOfTimeInWordsFromNow returns a string describing the relative time 141 // passed between a time and the current moment. 142 func DistanceOfTimeInWordsFromNow(to time.Time) string { 143 return DistanceOfTimeInWords(to, time.Now()) 144 } 145 146 type downloadedImageContextKey struct{} 147 148 type DownloadedImageContextContainer struct { 149 Images []*DownloadedImageInfo 150 } 151 152 type DownloadedImageInfo struct { 153 Slug string 154 URL *url.URL 155 Width int 156 157 // Internal 158 ext string `toml:"-"` 159 } 160 161 func (p *DownloadedImageInfo) OriginalExt() string { 162 if p.ext != "" { 163 return p.ext 164 } 165 166 p.ext = strings.ToLower(filepath.Ext(p.URL.Path)) 167 return p.ext 168 } 169 170 func DownloadedImageContext(ctx context.Context) (context.Context, *DownloadedImageContextContainer) { 171 container := &DownloadedImageContextContainer{} 172 return context.WithValue(ctx, downloadedImageContextKey{}, container), container 173 } 174 175 // DownloadedImage represents an image that's available remotely, and which will 176 // be downloaded and stored as the local target slug. This doesn't happen 177 // automatically though -- DownloadedImageContext must be called first to set a 178 // context container, and from there any downloaded image slugs and URLs can be 179 // extracted after all sources are rendered to be sent to mimage for processing. 180 func DownloadedImage(ctx context.Context, slug, imageURL string, width int) string { 181 v := ctx.Value(downloadedImageContextKey{}) 182 if v == nil { 183 panic("context key not set; DownloadedImageContext must be called") 184 } 185 186 u, err := url.Parse(imageURL) 187 if err != nil { 188 panic(fmt.Sprintf("error parsing image URL %q: %v", imageURL, err)) 189 } 190 191 container := v.(*DownloadedImageContextContainer) 192 container.Images = append(container.Images, &DownloadedImageInfo{slug, u, width, ""}) 193 194 return slug + strings.ToLower(filepath.Ext(u.Path)) 195 } 196 197 // Figure wraps a number of images into a figure and assigns them a caption as 198 // well as alt text. 199 func Figure(figCaption string, imgs ...*HTMLImage) template.HTML { 200 out := ` 201 <figure> 202 ` 203 204 for _, img := range imgs { 205 out += " " + string(img.render()) + "\n" 206 } 207 208 if figCaption != "" { 209 out += fmt.Sprintf(` <figcaption>%s</figcaption>`+"\n", figCaption) 210 } 211 212 out += "</figure>" 213 214 return template.HTML(strings.TrimSpace(out)) 215 } 216 217 // FigureSingle is a shortcut for creating a simple figure with a single image 218 // and with an alt that matches the caption. 219 func FigureSingle(figCaption, src string) template.HTML { 220 return Figure(figCaption, &HTMLImage{Alt: figCaption, Src: src}) 221 } 222 223 // FigureSingleWithClass is a shortcut for creating a simple figure with a 224 // single image and with an alt that matches the caption, and with an HTML 225 // class.. 226 func FigureSingleWithClass(figCaption, src, class string) template.HTML { 227 return Figure(figCaption, &HTMLImage{Alt: figCaption, Class: class, Src: src}) 228 } 229 230 // HTMLSafePassThrough passes a string through to the final render. This is 231 // especially useful for code samples that contain Go template syntax which 232 // shouldn't be rendered. 233 func HTMLSafePassThrough(s string) template.HTML { 234 return template.HTML(strings.TrimSpace(s)) 235 } 236 237 // HTMLElement represents an HTML element that can be rendered. 238 type HTMLElement interface { 239 render() template.HTML 240 } 241 242 // HTMLImage is a simple struct representing an HTML image to be rendered and 243 // some of the attributes it might have. 244 type HTMLImage struct { 245 Src string 246 Alt string 247 Class string 248 } 249 250 // htmlElementRenderer is an internal representation of an HTML element to make 251 // building one with a set of properties easier. 252 type htmlElementRenderer struct { 253 Name string 254 Attrs map[string]string 255 } 256 257 func (r *htmlElementRenderer) render() template.HTML { 258 pairs := make([]string, 0, len(r.Attrs)) 259 for name, val := range r.Attrs { 260 pairs = append(pairs, fmt.Sprintf(`%s="%s"`, name, val)) 261 } 262 263 // Sort the outgoing names so that we have something stable to test against 264 sort.Strings(pairs) 265 266 return template.HTML(fmt.Sprintf( 267 `<%s %s>`, 268 r.Name, 269 strings.Join(pairs, " "), 270 )) 271 } 272 273 func (img *HTMLImage) render() template.HTML { 274 element := htmlElementRenderer{ 275 Name: "img", 276 Attrs: map[string]string{ 277 "loading": "lazy", 278 "src": img.Src, 279 }, 280 } 281 282 if img.Alt != "" { 283 element.Attrs["alt"] = img.Alt 284 } 285 286 if ext := filepath.Ext(img.Src); ext != ".svg" { 287 retinaSource := strings.TrimSuffix(img.Src, ext) + "@2x" + ext 288 element.Attrs["srcset"] = fmt.Sprintf("%s 2x, %s 1x", retinaSource, img.Src) 289 } 290 291 if img.Class != "" { 292 element.Attrs["class"] = img.Class 293 } 294 295 return element.render() 296 } 297 298 // HTMLRender renders a series of mtemplate HTML elements. 299 func HTMLRender(elements ...HTMLElement) template.HTML { 300 rendered := make([]string, len(elements)) 301 302 for i, element := range elements { 303 rendered[i] = string(element.render()) 304 } 305 306 return template.HTML( 307 strings.Join(rendered, "\n"), 308 ) 309 } 310 311 // ImgSrcAndAlt is a shortcut for creating ImgSrcAndAlt. 312 func ImgSrcAndAlt(imgSrc, imgAlt string) *HTMLImage { 313 return &HTMLImage{imgSrc, imgAlt, ""} 314 } 315 316 // ImgSrcAndAltAndClass is a shortcut for creating ImgSrcAndAlt with a CSS 317 // class. 318 func ImgSrcAndAltAndClass(imgSrc, imgAlt, class string) *HTMLImage { 319 return &HTMLImage{imgSrc, imgAlt, class} 320 } 321 322 // FormatTime formats time according to the given format string. 323 func FormatTime(t time.Time, format string) string { 324 return toNonBreakingWhitespace(t.Format(format)) 325 } 326 327 // FormatTime formats time according to the given format string. 328 func FormatTimeRFC3339UTC(t time.Time) string { 329 return toNonBreakingWhitespace(t.UTC().Format(time.RFC3339)) 330 } 331 332 // FormatTimeSimpleDate formats time according to a relatively straightforward 333 // time format. 334 func FormatTimeSimpleDate(t time.Time) string { 335 return toNonBreakingWhitespace(t.Format("January 2, 2006")) 336 } 337 338 type mapVal struct { 339 key string 340 val interface{} 341 } 342 343 func Map(vals ...*mapVal) map[string]interface{} { 344 m := make(map[string]interface{}) 345 346 for _, val := range vals { 347 m[val.key] = val.val 348 } 349 350 return m 351 } 352 353 // MapVal generates a new map key/value for use with MapValAdd. 354 func MapVal(key string, val interface{}) *mapVal { //nolint:revive 355 return &mapVal{key, val} 356 } 357 358 // MapValAdd is a convenience helper for adding a new key and value to a shallow 359 // copy of the given map and returning it. 360 func MapValAdd(m map[string]interface{}, vals ...*mapVal) map[string]interface{} { 361 mCopy := make(map[string]interface{}, len(m)) 362 363 for k, v := range m { 364 mCopy[k] = v 365 } 366 367 for _, val := range vals { 368 mCopy[val.key] = val.val 369 } 370 371 return mCopy 372 } 373 374 // QueryEscape escapes a URL. 375 func QueryEscape(s string) string { 376 return url.QueryEscape(s) 377 } 378 379 func RomanNumeral(num int) string { 380 const maxRomanNumber int = 3999 381 382 if num > maxRomanNumber || num < 1 { 383 return strconv.Itoa(num) 384 } 385 386 conversions := []struct { 387 value int 388 digit string 389 }{ 390 {1000, "M"}, 391 {900, "CM"}, 392 {500, "D"}, 393 {400, "CD"}, 394 {100, "C"}, 395 {90, "XC"}, 396 {50, "L"}, 397 {40, "XL"}, 398 {10, "X"}, 399 {9, "IX"}, 400 {5, "V"}, 401 {4, "IV"}, 402 {1, "I"}, 403 } 404 405 var roman strings.Builder 406 for _, conversion := range conversions { 407 for num >= conversion.value { 408 roman.WriteString(conversion.digit) 409 num -= conversion.value 410 } 411 } 412 413 return roman.String() 414 } 415 416 // RoundToString rounds a float to a presentation-friendly string. 417 func RoundToString(f float64) string { 418 return fmt.Sprintf("%.1f", f) 419 } 420 421 func TimeIn(t time.Time, locationName string) time.Time { 422 location, err := time.LoadLocation(locationName) 423 if err != nil { 424 panic(err) 425 } 426 return t.In(location) 427 } 428 429 // To2X takes a 1x (standad resolution) image path and changes it to a 2x path 430 // by putting `@2x` into its name right before its extension. 431 func To2X(imagePath string) template.HTML { 432 parts := strings.Split(imagePath, ".") 433 434 if len(parts) < 2 { 435 return template.HTML(imagePath) 436 } 437 438 parts[len(parts)-2] = parts[len(parts)-2] + "@2x" 439 440 return template.HTML(strings.Join(parts, ".")) 441 } 442 443 ////////////////////////////////////////////////////////////////////////////// 444 // 445 // 446 // 447 // Private 448 // 449 // 450 // 451 ////////////////////////////////////////////////////////////////////////////// 452 453 // Look for any whitespace between HTML tags. 454 var whitespaceRE = regexp.MustCompile(`>\s+<`) 455 456 // Simply collapses certain HTML snippets by removing newlines and whitespace 457 // between tags. This is mainline used to make HTML snippets readable as 458 // constants, but then to make them fit a little more nicely into the rendered 459 // markup. 460 func collapseHTML(html string) string { 461 html = strings.ReplaceAll(html, "\n", "") 462 html = whitespaceRE.ReplaceAllString(html, "><") 463 html = strings.TrimSpace(html) 464 return html 465 } 466 467 // There is no "round" function built into Go :/. 468 func round(f float64) float64 { 469 return math.Floor(f + .5) 470 } 471 472 func toNonBreakingWhitespace(str string) string { 473 return strings.ReplaceAll(str, " ", " ") 474 }