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  }