decred.org/dcrdex@v1.0.5/client/webserver/template.go (about)

     1  // This code is available on the terms of the project LICENSE.md file,
     2  // also available online at https://blueoakcouncil.org/license/1.0.0.
     3  
     4  package webserver
     5  
     6  import (
     7  	"bytes"
     8  	"encoding/hex"
     9  	"fmt"
    10  	"html/template"
    11  	"io/fs"
    12  	"os"
    13  	"strings"
    14  
    15  	"runtime/debug"
    16  
    17  	"decred.org/dcrdex/client/intl"
    18  	"decred.org/dcrdex/client/webserver/locales"
    19  	"decred.org/dcrdex/dex/encode"
    20  	"golang.org/x/text/cases"
    21  	"golang.org/x/text/language"
    22  )
    23  
    24  // pageTemplate holds the information necessary to process a template. Also
    25  // holds information necessary to reload the templates for development.
    26  type pageTemplate struct {
    27  	preloads []string
    28  	template *template.Template
    29  }
    30  
    31  // templates is a template processor.
    32  type templates struct {
    33  	templates    map[string]pageTemplate
    34  	fs           fs.FS // must contain tmpl files at root
    35  	reloadOnExec bool
    36  	dict         map[string]*intl.Translation
    37  	titler       cases.Caser
    38  
    39  	addErr error
    40  }
    41  
    42  // newTemplates constructs a new templates.
    43  func newTemplates(folder, lang string) *templates {
    44  	embedded := folder == ""
    45  	t := &templates{
    46  		templates:    make(map[string]pageTemplate),
    47  		reloadOnExec: !embedded,
    48  	}
    49  
    50  	var found bool
    51  	if t.dict, found = locales.Locales[lang]; !found {
    52  		t.addErr = fmt.Errorf("no translation dictionary found for lang %q", lang)
    53  		return t
    54  	}
    55  	t.titler = cases.Title(language.MustParse(lang))
    56  
    57  	if embedded {
    58  		t.fs = htmlTmplSub
    59  		return t
    60  	}
    61  
    62  	if !folderExists(folder) {
    63  		t.addErr = fmt.Errorf("not using embedded site, but source directory not found at %s", folder)
    64  	}
    65  	t.fs = os.DirFS(folder)
    66  
    67  	return t
    68  }
    69  
    70  // translate a template file.
    71  func (t *templates) translate(name string) (string, error) {
    72  	rawTmpl, err := fs.ReadFile(t.fs, name+".tmpl")
    73  	if err != nil {
    74  		return "", fmt.Errorf("ReadFile error: %w", err)
    75  	}
    76  
    77  	for _, matchGroup := range locales.Tokens(rawTmpl) {
    78  		if len(matchGroup) != 2 {
    79  			return "", fmt.Errorf("can't parse match group: %v", matchGroup)
    80  		}
    81  		token, key := matchGroup[0], string(matchGroup[1])
    82  
    83  		var toTitle bool
    84  		var found bool
    85  		var replacement *intl.Translation
    86  		if titleKey := strings.TrimPrefix(key, ":title:"); titleKey != key {
    87  			// Check if there's a value for :title:key. Especially for languages
    88  			// that do not work well with cases.Caser, e.g zh-cn.
    89  			if replacement, found = t.dict[key]; !found {
    90  				toTitle = true
    91  				key = titleKey
    92  			}
    93  		}
    94  
    95  		if !found {
    96  			if replacement, found = t.dict[key]; !found {
    97  				if replacement, found = locales.EnUS[key]; !found {
    98  					return "", fmt.Errorf("warning: no translation text for key %q", key)
    99  				}
   100  			}
   101  		}
   102  
   103  		if toTitle {
   104  			replacement.T = t.titler.String(replacement.T)
   105  		}
   106  
   107  		rawTmpl = bytes.ReplaceAll(rawTmpl, token, []byte(replacement.T))
   108  	}
   109  
   110  	return string(rawTmpl), nil
   111  }
   112  
   113  // addTemplate adds a new template. It can be embed from the binary or not, to
   114  // help with development. The template is specified with a name, which
   115  // must also be the base name of a file in the templates folder. Any preloads
   116  // must also be base names of files in the template folder, which will be loaded
   117  // in order before the name template is processed. addTemplate returns itself
   118  // and defers errors to facilitate chaining.
   119  func (t *templates) addTemplate(name string, preloads ...string) *templates {
   120  	if t.addErr != nil {
   121  		return t
   122  	}
   123  
   124  	tmpl := template.New(name).Funcs(templateFuncs)
   125  
   126  	// Translate and parse each template for this page.
   127  	for _, subName := range append(preloads, name) {
   128  		localized, err := t.translate(subName)
   129  		if err != nil {
   130  			t.addErr = fmt.Errorf("error translating templates: %w", err)
   131  			return t
   132  		}
   133  
   134  		tmpl, err = tmpl.Parse(localized)
   135  		if err != nil {
   136  			t.addErr = fmt.Errorf("error adding template %s: %w", name, err)
   137  			return t
   138  		}
   139  	}
   140  
   141  	t.templates[name] = pageTemplate{
   142  		preloads: preloads,
   143  		template: tmpl,
   144  	}
   145  	return t
   146  }
   147  
   148  // buildErr returns any error encountered during addTemplate. The error is
   149  // cleared.
   150  func (t *templates) buildErr() error {
   151  	err := t.addErr
   152  	t.addErr = nil
   153  	return err
   154  }
   155  
   156  var commitHash = func() string {
   157  	if info, ok := debug.ReadBuildInfo(); ok {
   158  		for _, setting := range info.Settings {
   159  			if setting.Key == "vcs.revision" && len(setting.Value) >= 8 {
   160  				return setting.Value
   161  			}
   162  		}
   163  	}
   164  
   165  	return ""
   166  }()
   167  
   168  // exec executes the specified input template using the supplied data, and
   169  // writes the result into a string. If the template fails to execute or isn't
   170  // found, a non-nil error will be returned. Check it before writing to the
   171  // client, otherwise you might as well execute directly into your response
   172  // writer instead of the internal buffer of this function.
   173  //
   174  // The template will be reloaded if using on-disk (not embedded) templates.
   175  //
   176  // DRAFT NOTE: Might consider writing directly to the the buffer here. Could
   177  // still set the error code appropriately.
   178  func (t *templates) exec(name string, data any) (string, error) {
   179  	tmpl, found := t.templates[name]
   180  	if !found {
   181  		return "", fmt.Errorf("template %q not found", name)
   182  	}
   183  
   184  	if t.reloadOnExec {
   185  		// Retranslate and re-parse the template.
   186  		if err := t.addTemplate(name, tmpl.preloads...).buildErr(); err != nil {
   187  			// No need to return the error because we still want to display the
   188  			// page.
   189  			log.Errorf("Failed to reload HTML template %q: %v", name, err)
   190  		} else {
   191  			log.Debugf("reloaded HTML template %q", name)
   192  
   193  			// Grab the new pageTemplate
   194  			tmpl = t.templates[name]
   195  		}
   196  	}
   197  
   198  	var page strings.Builder
   199  	err := tmpl.template.ExecuteTemplate(&page, name, data)
   200  	return page.String(), err
   201  }
   202  
   203  // templateFuncs are able to be called during template execution.
   204  var templateFuncs = template.FuncMap{
   205  	"toUpper": strings.ToUpper,
   206  	// logoPath gets the logo image path for the base asset of the specified
   207  	// market.
   208  	"logoPath": func(symbol string) string {
   209  		return "/img/coins/" + strings.ToLower(symbol) + ".png"
   210  	},
   211  	"x100": func(v float32) float32 {
   212  		return v * 100
   213  	},
   214  	"dummyExchangeLogo": func(host string) string {
   215  		if len(host) == 0 {
   216  			return "/img/coins/z.png"
   217  		}
   218  		char := host[0]
   219  		if char < 97 || char > 122 {
   220  			return "/img/coins/z.png"
   221  		}
   222  		return "/img/coins/" + string(char) + ".png"
   223  	},
   224  	"baseAssetSymbol": func(symbol string) string {
   225  		parts := strings.Split(symbol, ".")
   226  		if len(parts) == 0 {
   227  			return "wtf"
   228  		}
   229  		return parts[0]
   230  	},
   231  	"commitHash": func(allowRandom bool) string {
   232  		if commitHash != "" {
   233  			return commitHash[:8]
   234  		}
   235  
   236  		if allowRandom {
   237  			// If the commit hash is not available, return a random string.
   238  			// This is useful in invalidating JS and CSS file caches.
   239  			return hex.EncodeToString(encode.RandomBytes(4))
   240  		}
   241  
   242  		return "unknown"
   243  	},
   244  }