449 lines
13 KiB
Go
449 lines
13 KiB
Go
package templatenest
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"io/ioutil"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Hash map[string]interface{}
|
|
|
|
// Option holds configuration for TemplateNest
|
|
type Option struct {
|
|
Delimiters [2]string
|
|
NameLabel *string // Identify the template to be used
|
|
TemplateDir string // Directory where templates are located
|
|
TemplateExtension *string // Appended on the label to idenfity the template
|
|
DieOnBadParams bool // Attempt to populate a variable that doesn't exist should result in an error
|
|
ShowLabels bool // Prepend & Append a string to every template, helpful in debugging
|
|
CommentDelimiters [2]string // Used in conjunction with ShowLabels, if HTML then use '<!--', '-->'
|
|
FixedIndent bool // Intended to improve readability when inspecting nested templates
|
|
TokenEscapeChar string // Escapes a token delimiter, i.e. if set to '\' then variables that have '\' prefix won't be replaced
|
|
DefaultsNamespaceChar *string
|
|
Defaults Hash // Provide a hash of default values that are substituted if template hash does not provide a value
|
|
NoEscapeInput bool // By default all template values are html escaped
|
|
}
|
|
|
|
type TemplateNest struct {
|
|
option Option
|
|
defaultsFlat Hash
|
|
cache map[string]TemplateFileIndex
|
|
}
|
|
|
|
// TemplateFileIndex represents an indexed template file.
|
|
type TemplateFileIndex struct {
|
|
Contents string
|
|
LastModified time.Time // Last modified timestamp of the template file
|
|
Variables []TemplateFileVariable // List of variables in the template file
|
|
VariableNames map[string]struct{} // Set of variable names
|
|
}
|
|
|
|
// TemplateFileVariable represents a variable in a template file.
|
|
type TemplateFileVariable struct {
|
|
Name string
|
|
StartPosition uint // Start position of the complete variable string (including delimiters)
|
|
EndPosition uint // End position of the complete variable string (including delimiters)
|
|
IndentLevel uint // Indentation level of the variable
|
|
EscapedToken bool // Indicates if the variable was escaped with token escape character
|
|
}
|
|
|
|
func New(opts Option) (*TemplateNest, error) {
|
|
// Check if the TemplateDir exists and is a directory.
|
|
templateDirInfo, err := os.Stat(opts.TemplateDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("template dir `%s` does not exist", opts.TemplateDir)
|
|
}
|
|
return nil, fmt.Errorf("error checking template dir: %w", err)
|
|
}
|
|
if !templateDirInfo.IsDir() {
|
|
return nil, fmt.Errorf("template dir `%s` is not a directory", opts.TemplateDir)
|
|
}
|
|
|
|
// Set defaults for options that the user hasn't provided.
|
|
if opts.Delimiters == [2]string{} {
|
|
opts.Delimiters = [2]string{"<!--%", "%-->"}
|
|
}
|
|
if opts.CommentDelimiters == [2]string{} {
|
|
opts.CommentDelimiters = [2]string{"<!--", "-->"}
|
|
}
|
|
if opts.NameLabel == nil {
|
|
nameLabel := "TEMPLATE"
|
|
opts.NameLabel = &nameLabel
|
|
}
|
|
if opts.TemplateExtension == nil {
|
|
ext := "html"
|
|
opts.TemplateExtension = &ext
|
|
}
|
|
if opts.DefaultsNamespaceChar == nil {
|
|
namespaceChar := "."
|
|
opts.DefaultsNamespaceChar = &namespaceChar
|
|
}
|
|
if opts.Defaults == nil {
|
|
opts.Defaults = make(map[string]interface{})
|
|
}
|
|
|
|
// Initialize TemplateNest with the final options.
|
|
nest := &TemplateNest{
|
|
option: opts,
|
|
cache: make(map[string]TemplateFileIndex),
|
|
defaultsFlat: flattenMap(opts.Defaults, "", opts.DefaultsNamespaceChar),
|
|
}
|
|
|
|
// Walk through the template directory and index the templates.
|
|
err = filepath.Walk(opts.TemplateDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Skip directories
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
// Check if the file has the correct extension.
|
|
if *opts.TemplateExtension != "" &&
|
|
!strings.HasSuffix(info.Name(), "."+*opts.TemplateExtension) {
|
|
return nil
|
|
}
|
|
|
|
// Index the template and store it in the cache.
|
|
templateIndex, err := nest.index(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get the relative path of the file.
|
|
relPath, err := filepath.Rel(opts.TemplateDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remove the extension from the relative path.
|
|
templateName := relPath
|
|
if *opts.TemplateExtension != "" {
|
|
templateName = strings.TrimSuffix(relPath, "."+*opts.TemplateExtension)
|
|
}
|
|
|
|
nest.cache[templateName] = templateIndex
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return nest, nil
|
|
}
|
|
|
|
// flattenMap flattens a nested map[string]interface{} into a flat map with
|
|
// dot-separated keys.
|
|
func flattenMap(input Hash, parentKey string, separator *string) Hash {
|
|
result := make(map[string]interface{})
|
|
for key, value := range input {
|
|
fullKey := key
|
|
if parentKey != "" {
|
|
fullKey = parentKey + *separator + key
|
|
}
|
|
|
|
// Check if the value is a nested map
|
|
if nestedMap, ok := value.(Hash); ok {
|
|
// If separator is nil then there is no point in the user passing us
|
|
// a nested map.
|
|
if separator != nil {
|
|
for k, v := range flattenMap(nestedMap, fullKey, separator) {
|
|
result[k] = v
|
|
}
|
|
}
|
|
} else {
|
|
result[fullKey] = value
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (nest *TemplateNest) index(filePath string) (TemplateFileIndex, error) {
|
|
contents, err := ioutil.ReadFile(filePath)
|
|
if err != nil {
|
|
return TemplateFileIndex{}, fmt.Errorf("error reading file (`%s`): %w", filePath, err)
|
|
}
|
|
|
|
// Capture last modified time
|
|
fileInfo, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return TemplateFileIndex{}, fmt.Errorf("error getting file (`%s`) info: %w", filePath, err)
|
|
}
|
|
|
|
variableNames := make(map[string]struct{})
|
|
variables := []TemplateFileVariable{}
|
|
|
|
delimiterStart := nest.option.Delimiters[0]
|
|
delimiterEnd := nest.option.Delimiters[1]
|
|
escapeChar := nest.option.TokenEscapeChar
|
|
|
|
re := regexp.MustCompile(fmt.Sprintf(
|
|
"%s\\s*(.+?)\\s*%s", regexp.QuoteMeta(delimiterStart), regexp.QuoteMeta(delimiterEnd),
|
|
))
|
|
|
|
contentsStr := string(contents)
|
|
matches := re.FindAllStringSubmatchIndex(string(contents), -1)
|
|
for _, match := range matches {
|
|
if len(match) < 4 {
|
|
continue
|
|
}
|
|
|
|
startIdx := match[0]
|
|
endIdx := match[1]
|
|
nameStartIdx := match[2]
|
|
nameEndIdx := match[3]
|
|
|
|
varName := string(contents[nameStartIdx:nameEndIdx])
|
|
variableNames[varName] = struct{}{}
|
|
|
|
// If token escape char is set then look behind for it and if we
|
|
// find the escape char then we're only going to remove the escape
|
|
// char and not remove this variable.
|
|
if escapeChar != "" && startIdx >= len(escapeChar) {
|
|
escapeStartIdx := startIdx - len(escapeChar)
|
|
if contentsStr[escapeStartIdx:startIdx] == escapeChar {
|
|
variables = append(variables, TemplateFileVariable{
|
|
Name: "",
|
|
StartPosition: uint(escapeStartIdx),
|
|
EndPosition: uint(escapeStartIdx + len(escapeChar)),
|
|
EscapedToken: true,
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
|
|
// If fixed indent is enabled then record the indent level for this
|
|
// variable. To get the indent level we look at each character in
|
|
// reverse from the start position of the variable until we find a
|
|
// newline character.
|
|
indentLevel := 0
|
|
if nest.option.FixedIndent {
|
|
// If we do not encounter a newline then that means this variable is
|
|
// on the first line, we take the start position as the indent
|
|
// level.
|
|
lineStartIdx := strings.LastIndex(contentsStr[:startIdx], "\n")
|
|
if lineStartIdx == -1 {
|
|
indentLevel = startIdx
|
|
} else {
|
|
indentLevel = startIdx - lineStartIdx - 1
|
|
}
|
|
}
|
|
|
|
variables = append(variables, TemplateFileVariable{
|
|
Name: varName,
|
|
StartPosition: uint(startIdx),
|
|
EndPosition: uint(endIdx),
|
|
IndentLevel: uint(indentLevel),
|
|
})
|
|
}
|
|
|
|
fileIndex := TemplateFileIndex{
|
|
Contents: contentsStr,
|
|
LastModified: fileInfo.ModTime(),
|
|
VariableNames: variableNames,
|
|
Variables: variables,
|
|
}
|
|
|
|
return fileIndex, nil
|
|
}
|
|
|
|
// getTemplateFilePath takes a template name and returns the file path for the
|
|
// template.
|
|
func (nest *TemplateNest) getTemplateFilePath(templateName string) string {
|
|
if *nest.option.TemplateExtension == "" {
|
|
return filepath.Join(nest.option.TemplateDir, templateName)
|
|
}
|
|
|
|
return filepath.Join(
|
|
nest.option.TemplateDir,
|
|
fmt.Sprintf("%s.%s", templateName, *nest.option.TemplateExtension),
|
|
)
|
|
}
|
|
|
|
func (nest *TemplateNest) MustRender(toRender interface{}) string {
|
|
render, err := nest.Render(toRender)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return render
|
|
}
|
|
|
|
func (nest *TemplateNest) Render(toRender interface{}) (string, error) {
|
|
if reflect.TypeOf(toRender).Kind() == reflect.Slice {
|
|
return nest.renderSlice(toRender)
|
|
}
|
|
|
|
switch v := toRender.(type) {
|
|
case nil:
|
|
return "", nil
|
|
|
|
case bool:
|
|
return fmt.Sprintf("%t", v), nil
|
|
|
|
case string:
|
|
if nest.option.NoEscapeInput {
|
|
return v, nil
|
|
}
|
|
return html.EscapeString(v), nil
|
|
|
|
case float32:
|
|
return strconv.FormatFloat(float64(v), 'f', -1, 32), nil
|
|
case float64:
|
|
return strconv.FormatFloat(v, 'f', -1, 64), nil
|
|
case int:
|
|
return strconv.Itoa(v), nil
|
|
case int8:
|
|
return strconv.FormatInt(int64(v), 10), nil
|
|
case int16:
|
|
return strconv.FormatInt(int64(v), 10), nil
|
|
case int32:
|
|
return strconv.FormatInt(int64(v), 10), nil
|
|
case int64:
|
|
return strconv.FormatInt(v, 10), nil
|
|
case uint:
|
|
return strconv.FormatUint(uint64(v), 10), nil
|
|
case uint8:
|
|
return strconv.FormatUint(uint64(v), 10), nil
|
|
case uint16:
|
|
return strconv.FormatUint(uint64(v), 10), nil
|
|
case uint32:
|
|
return strconv.FormatUint(uint64(v), 10), nil
|
|
case uint64:
|
|
return strconv.FormatUint(v, 10), nil
|
|
|
|
case Hash:
|
|
return nest.renderHash(v)
|
|
|
|
case map[string]interface{}:
|
|
return nest.renderHash(v)
|
|
|
|
default:
|
|
return "", fmt.Errorf("unsupported template hash value type: %T: %+v", v, v)
|
|
}
|
|
}
|
|
|
|
func (nest *TemplateNest) renderSlice(toRender interface{}) (string, error) {
|
|
val := reflect.ValueOf(toRender)
|
|
if val.Kind() != reflect.Slice {
|
|
return "", fmt.Errorf("expected slice, got: %T", toRender)
|
|
}
|
|
|
|
var rendered strings.Builder
|
|
for i := 0; i < val.Len(); i++ {
|
|
element := val.Index(i).Interface()
|
|
subRender, err := nest.Render(element)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rendered.WriteString(subRender)
|
|
}
|
|
return rendered.String(), nil
|
|
}
|
|
|
|
func (nest *TemplateNest) renderHash(hash map[string]interface{}) (string, error) {
|
|
tLabel, ok := hash[*nest.option.NameLabel]
|
|
if !ok {
|
|
return "", fmt.Errorf(
|
|
"encountered hash with no name label (name label: `%s`)", nest.option.NameLabel,
|
|
)
|
|
}
|
|
|
|
tName, ok := tLabel.(string)
|
|
if !ok {
|
|
return "", fmt.Errorf(
|
|
"encountered hash with invalid name label type: %+v", tLabel,
|
|
)
|
|
}
|
|
|
|
tFile := nest.getTemplateFilePath(tName)
|
|
fileInfo, err := os.Stat(tFile)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error getting file info: %w", err)
|
|
}
|
|
|
|
tIndex, exists := nest.cache[tName]
|
|
|
|
// If cache doesn't exist or has expired, re-index the file.
|
|
if !exists || fileInfo.ModTime().After(tIndex.LastModified) {
|
|
newIndex, err := nest.index(tFile)
|
|
if err != nil {
|
|
return "", fmt.Errorf("file index failed: %w", err)
|
|
}
|
|
tIndex = newIndex
|
|
}
|
|
|
|
if nest.option.DieOnBadParams {
|
|
for k, _ := range hash {
|
|
// If a variable in template hash is not present in template
|
|
// file and it's not the template label then it's a bad param.
|
|
_, exists := tIndex.VariableNames[k]
|
|
if !exists && k != *nest.option.NameLabel {
|
|
return "", fmt.Errorf(
|
|
"bad params in template hash, variable not present in template file: `%s`", k,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
rendered := tIndex.Contents
|
|
for i := len(tIndex.Variables) - 1; i >= 0; i-- {
|
|
variable := tIndex.Variables[i]
|
|
if variable.EscapedToken {
|
|
rendered = rendered[:variable.StartPosition] + rendered[variable.EndPosition:]
|
|
continue
|
|
}
|
|
|
|
// If the variable doesn't exist in template hash then replace it
|
|
// with an empty string.
|
|
replacement := ""
|
|
|
|
value, exists := hash[variable.Name]
|
|
defaultValue, defaultExists := nest.defaultsFlat[variable.Name]
|
|
|
|
if exists || defaultExists {
|
|
if !exists {
|
|
value = defaultValue
|
|
}
|
|
|
|
subRender, err := nest.Render(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if nest.option.FixedIndent && variable.IndentLevel != 0 {
|
|
indentReplacement := "\n" + strings.Repeat(" ", int(variable.IndentLevel))
|
|
subRender = strings.ReplaceAll(subRender, "\n", indentReplacement)
|
|
}
|
|
|
|
replacement = subRender
|
|
}
|
|
|
|
// Replace in rendered template
|
|
rendered = rendered[:variable.StartPosition] + replacement + rendered[variable.EndPosition:]
|
|
}
|
|
|
|
if nest.option.ShowLabels {
|
|
labelStart := fmt.Sprintf(
|
|
"%s BEGIN %s %s\n",
|
|
nest.option.CommentDelimiters[0], tName, nest.option.CommentDelimiters[1],
|
|
)
|
|
labelEnd := fmt.Sprintf(
|
|
"%s END %s %s\n",
|
|
nest.option.CommentDelimiters[0], tName, nest.option.CommentDelimiters[1],
|
|
)
|
|
rendered = labelStart + rendered + labelEnd
|
|
}
|
|
|
|
return strings.TrimSpace(rendered), nil
|
|
}
|