decred.org/dcrdex@v1.0.3/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 // exec executes the specified input template using the supplied data, and 157 // writes the result into a string. If the template fails to execute or isn't 158 // found, a non-nil error will be returned. Check it before writing to the 159 // client, otherwise you might as well execute directly into your response 160 // writer instead of the internal buffer of this function. 161 // 162 // The template will be reloaded if using on-disk (not embedded) templates. 163 // 164 // DRAFT NOTE: Might consider writing directly to the the buffer here. Could 165 // still set the error code appropriately. 166 func (t *templates) exec(name string, data any) (string, error) { 167 tmpl, found := t.templates[name] 168 if !found { 169 return "", fmt.Errorf("template %q not found", name) 170 } 171 172 if t.reloadOnExec { 173 // Retranslate and re-parse the template. 174 if err := t.addTemplate(name, tmpl.preloads...).buildErr(); err != nil { 175 // No need to return the error because we still want to display the 176 // page. 177 log.Errorf("Failed to reload HTML template %q: %v", name, err) 178 } else { 179 log.Debugf("reloaded HTML template %q", name) 180 181 // Grab the new pageTemplate 182 tmpl = t.templates[name] 183 } 184 } 185 186 var page strings.Builder 187 err := tmpl.template.ExecuteTemplate(&page, name, data) 188 return page.String(), err 189 } 190 191 var commit = func() string { 192 if info, ok := debug.ReadBuildInfo(); ok { 193 for _, setting := range info.Settings { 194 if setting.Key == "vcs.revision" && len(setting.Value) >= 8 { 195 return setting.Value 196 } 197 } 198 } 199 200 return hex.EncodeToString(encode.RandomBytes(4)) 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() string { 232 return commit[:8] 233 }, 234 }