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  }