github.com/PDOK/gokoala@v0.50.6/internal/engine/template.go (about) 1 package engine 2 3 import ( 4 "bytes" 5 "fmt" 6 htmltemplate "html/template" 7 "log" 8 "net/url" 9 "path/filepath" 10 "strings" 11 texttemplate "text/template" 12 13 "github.com/PDOK/gokoala/config" 14 15 "github.com/PDOK/gokoala/internal/engine/util" 16 sprig "github.com/go-task/slim-sprig" 17 gomarkdown "github.com/gomarkdown/markdown" 18 gomarkdownhtml "github.com/gomarkdown/markdown/html" 19 gomarkdownparser "github.com/gomarkdown/markdown/parser" 20 "github.com/nicksnyder/go-i18n/v2/i18n" 21 stripmd "github.com/writeas/go-strip-markdown/v2" 22 "golang.org/x/text/language" 23 ) 24 25 const ( 26 layoutFile = "layout.go.html" 27 ) 28 29 var ( 30 globalTemplateFuncs texttemplate.FuncMap 31 ) 32 33 func init() { 34 customFuncs := texttemplate.FuncMap{ 35 // custom template functions 36 "markdown": markdown, 37 "unmarkdown": unmarkdown, 38 } 39 sprigFuncs := sprig.FuncMap() // we also support https://github.com/go-task/slim-sprig functions 40 globalTemplateFuncs = combineFuncMaps(customFuncs, sprigFuncs) 41 } 42 43 // TemplateKey unique key to register and lookup Go templates 44 type TemplateKey struct { 45 // Name of the template, the filename including extension 46 Name string 47 48 // Directory in which the template resides 49 Directory string 50 51 // Format the file format based on the filename extension, 'html' or 'json' 52 Format string 53 54 // Language of the contents of the template 55 Language language.Tag 56 57 // Optional. Only required when you want to render the same template multiple times (with different content). 58 // By specifying an 'instance name' you can refer to a certain instance of a rendered template later on. 59 InstanceName string 60 } 61 62 // TemplateData the data/variables passed as an argument into the template. 63 type TemplateData struct { 64 // Config set during startup based on the given config file 65 Config *config.Config 66 67 // Params optional parameters not part of GoKoala's config file. You can use 68 // this to provide extra data to a template at rendering time. 69 Params any 70 71 // Breadcrumb path to the page, in key-value pairs of name->path 72 Breadcrumbs []Breadcrumb 73 74 // Request URL 75 url *url.URL 76 } 77 78 // AvailableFormats returns the output formats available for the current page 79 func (td *TemplateData) AvailableFormats() map[string]string { 80 if td.url != nil && strings.Contains(td.url.Path, "/items") { 81 return td.AvailableFormatsFeatures() 82 } 83 return OutputFormatDefault 84 } 85 86 // AvailableFormatsFeatures convenience function 87 func (td *TemplateData) AvailableFormatsFeatures() map[string]string { 88 return OutputFormatFeatures 89 } 90 91 // QueryString returns ?=foo=a&bar=b style query string of the current page 92 func (td *TemplateData) QueryString(format string) string { 93 if td.url != nil { 94 q := td.url.Query() 95 if format != "" { 96 q.Set(FormatParam, format) 97 } 98 return "?" + q.Encode() 99 } 100 return fmt.Sprintf("?%s=%s", FormatParam, format) 101 } 102 103 type Breadcrumb struct { 104 Name string 105 Path string 106 } 107 108 // NewTemplateKey build TemplateKeys 109 func NewTemplateKey(path string) TemplateKey { 110 return NewTemplateKeyWithName(path, "") 111 } 112 113 func NewTemplateKeyWithLanguage(path string, language language.Tag) TemplateKey { 114 return NewTemplateKeyWithNameAndLanguage(path, "", language) 115 } 116 117 // NewTemplateKeyWithName build TemplateKey with InstanceName (see docs in struct) 118 func NewTemplateKeyWithName(path string, instanceName string) TemplateKey { 119 return NewTemplateKeyWithNameAndLanguage(path, instanceName, language.Dutch) 120 } 121 122 func NewTemplateKeyWithNameAndLanguage(path string, instanceName string, language language.Tag) TemplateKey { 123 cleanPath := filepath.Clean(path) 124 return TemplateKey{ 125 Name: filepath.Base(cleanPath), 126 Directory: filepath.Dir(cleanPath), 127 Format: strings.TrimPrefix(filepath.Ext(path), "."), 128 Language: language, 129 InstanceName: instanceName, 130 } 131 } 132 133 func ExpandTemplateKey(key TemplateKey, language language.Tag) TemplateKey { 134 copyKey := key 135 copyKey.Language = language 136 return copyKey 137 } 138 139 type Templates struct { 140 // ParsedTemplates templates loaded from disk and parsed to an in-memory Go representation. 141 ParsedTemplates map[TemplateKey]any 142 143 // RenderedTemplates templates parsed + rendered to their actual output format like JSON, HTMl, etc. 144 // We prefer pre-rendered templates whenever possible. These are stored in this map. 145 RenderedTemplates map[TemplateKey][]byte 146 147 config *config.Config 148 localizers map[language.Tag]i18n.Localizer 149 } 150 151 func newTemplates(config *config.Config) *Templates { 152 templates := &Templates{ 153 ParsedTemplates: make(map[TemplateKey]any), 154 RenderedTemplates: make(map[TemplateKey][]byte), 155 config: config, 156 localizers: newLocalizers(config.AvailableLanguages), 157 } 158 return templates 159 } 160 161 func (t *Templates) getParsedTemplate(key TemplateKey) (any, error) { 162 if parsedTemplate, ok := t.ParsedTemplates[key]; ok { 163 return parsedTemplate, nil 164 } 165 return nil, fmt.Errorf("no parsed template with name %s", key.Name) 166 } 167 168 func (t *Templates) getRenderedTemplate(key TemplateKey) ([]byte, error) { 169 if RenderedTemplate, ok := t.RenderedTemplates[key]; ok { 170 return RenderedTemplate, nil 171 } 172 return nil, fmt.Errorf("no rendered template with name %s", key.Name) 173 } 174 175 func (t *Templates) parseAndSaveTemplate(key TemplateKey) { 176 for lang := range t.localizers { 177 keyWithLang := ExpandTemplateKey(key, lang) 178 if key.Format == FormatHTML { 179 _, parsed := t.parseHTMLTemplate(keyWithLang, lang) 180 t.ParsedTemplates[keyWithLang] = parsed 181 } else { 182 _, parsed := t.parseNonHTMLTemplate(keyWithLang, lang) 183 t.ParsedTemplates[keyWithLang] = parsed 184 } 185 } 186 } 187 188 func (t *Templates) renderAndSaveTemplate(key TemplateKey, breadcrumbs []Breadcrumb, params any) { 189 for lang := range t.localizers { 190 var result []byte 191 if key.Format == FormatHTML { 192 file, parsed := t.parseHTMLTemplate(key, lang) 193 result = t.renderHTMLTemplate(parsed, nil, params, breadcrumbs, file) 194 } else { 195 file, parsed := t.parseNonHTMLTemplate(key, lang) 196 result = t.renderNonHTMLTemplate(parsed, params, key, file) 197 } 198 199 // Store rendered template per language 200 key.Language = lang 201 t.RenderedTemplates[key] = result 202 } 203 } 204 205 func (t *Templates) parseHTMLTemplate(key TemplateKey, lang language.Tag) (string, *htmltemplate.Template) { 206 file := filepath.Clean(filepath.Join(key.Directory, key.Name)) 207 templateFuncs := t.createTemplateFuncs(lang) 208 parsed := htmltemplate.Must(htmltemplate.New(layoutFile). 209 Funcs(templateFuncs).ParseFiles(templatesDir+layoutFile, file)) 210 return file, parsed 211 } 212 213 func (t *Templates) renderHTMLTemplate(parsed *htmltemplate.Template, url *url.URL, 214 params any, breadcrumbs []Breadcrumb, file string) []byte { 215 216 var rendered bytes.Buffer 217 if err := parsed.Execute(&rendered, &TemplateData{ 218 Config: t.config, 219 Params: params, 220 Breadcrumbs: breadcrumbs, 221 url: url, 222 }); err != nil { 223 log.Fatalf("failed to execute HTML template %s, error: %v", file, err) 224 } 225 return rendered.Bytes() 226 } 227 228 func (t *Templates) parseNonHTMLTemplate(key TemplateKey, lang language.Tag) (string, *texttemplate.Template) { 229 file := filepath.Clean(filepath.Join(key.Directory, key.Name)) 230 templateFuncs := t.createTemplateFuncs(lang) 231 parsed := texttemplate.Must(texttemplate.New(filepath.Base(file)). 232 Funcs(templateFuncs).Parse(util.ReadFile(file))) 233 return file, parsed 234 } 235 236 func (t *Templates) renderNonHTMLTemplate(parsed *texttemplate.Template, params any, key TemplateKey, file string) []byte { 237 var rendered bytes.Buffer 238 if err := parsed.Execute(&rendered, &TemplateData{ 239 Config: t.config, 240 Params: params, 241 }); err != nil { 242 log.Fatalf("failed to execute template %s, error: %v", file, err) 243 } 244 245 var result = rendered.Bytes() 246 if strings.Contains(key.Format, FormatJSON) { 247 // pretty print all JSON (or derivatives like TileJSON) 248 result = util.PrettyPrintJSON(result, key.Name) 249 } 250 return result 251 } 252 253 func (t *Templates) createTemplateFuncs(lang language.Tag) map[string]any { 254 return combineFuncMaps(globalTemplateFuncs, texttemplate.FuncMap{ 255 // create func just-in-time based on TemplateKey 256 "i18n": func(messageID string) htmltemplate.HTML { 257 localizer := t.localizers[lang] 258 translated := localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: messageID}) 259 return htmltemplate.HTML(translated) //nolint:gosec // since we trust our language files 260 }, 261 }) 262 } 263 264 // combine given FuncMaps 265 func combineFuncMaps(funcMaps ...map[string]any) map[string]any { 266 result := make(map[string]any) 267 for _, funcMap := range funcMaps { 268 for k, v := range funcMap { 269 result[k] = v 270 } 271 } 272 return result 273 } 274 275 // markdown turn Markdown into HTML 276 func markdown(s *string) htmltemplate.HTML { 277 if s == nil { 278 return "" 279 } 280 // always normalize newlines, this library only supports Unix LF newlines 281 md := gomarkdown.NormalizeNewlines([]byte(*s)) 282 283 // create Markdown parser 284 extensions := gomarkdownparser.CommonExtensions 285 parser := gomarkdownparser.NewWithExtensions(extensions) 286 287 // parse Markdown into AST tree 288 doc := parser.Parse(md) 289 290 // create HTML renderer 291 htmlFlags := gomarkdownhtml.CommonFlags | gomarkdownhtml.HrefTargetBlank | gomarkdownhtml.SkipHTML 292 renderer := gomarkdownhtml.NewRenderer(gomarkdownhtml.RendererOptions{Flags: htmlFlags}) 293 294 return htmltemplate.HTML(gomarkdown.Render(doc, renderer)) //nolint:gosec 295 } 296 297 // unmarkdown remove Markdown, so we can use the given string in non-HTML (JSON) output 298 func unmarkdown(s *string) string { 299 if s == nil { 300 return "" 301 } 302 withoutMarkdown := stripmd.Strip(*s) 303 withoutLinebreaks := strings.ReplaceAll(withoutMarkdown, "\n", " ") 304 return withoutLinebreaks 305 }