template-nest-go/template_nest.go

405 lines
11 KiB
Go
Raw Normal View History

2024-11-16 04:13:48 +00:00
package templatenest
import (
"fmt"
2024-11-16 12:03:15 +00:00
"html"
2024-11-16 04:13:48 +00:00
"io/ioutil"
"os"
"path/filepath"
2024-11-16 12:50:20 +00:00
"reflect"
2024-11-16 04:13:48 +00:00
"regexp"
"strings"
"time"
)
type Hash map[string]interface{}
// Option holds configuration for TemplateNest
type Option struct {
2024-11-16 08:08:22 +00:00
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
2024-11-16 12:03:15 +00:00
NoEscapeInput bool // By default all template values are html escaped
2024-11-16 04:13:48 +00:00
}
type TemplateNest struct {
2024-11-16 12:03:15 +00:00
option Option
defaultsFlat Hash
cache map[string]TemplateFileIndex
2024-11-16 04:13:48 +00:00
}
// 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{"<!--%", "%-->"}
}
2024-11-16 05:10:45 +00:00
if opts.CommentDelimiters == [2]string{} {
opts.CommentDelimiters = [2]string{"<!--", "-->"}
}
2024-11-16 04:13:48 +00:00
if opts.NameLabel == "" {
opts.NameLabel = "TEMPLATE"
}
if opts.TemplateExtension == "" {
opts.TemplateExtension = "html"
}
2024-11-16 08:08:22 +00:00
if opts.DefaultsNamespaceChar == "" {
opts.DefaultsNamespaceChar = "."
}
if opts.Defaults == nil {
opts.Defaults = make(map[string]interface{})
}
2024-11-16 04:13:48 +00:00
// Initialize TemplateNest with the final options.
nest := &TemplateNest{
2024-11-16 12:03:15 +00:00
option: opts,
cache: make(map[string]TemplateFileIndex),
defaultsFlat: FlattenMap(opts.Defaults, "", opts.DefaultsNamespaceChar),
2024-11-16 04:13:48 +00:00
}
// 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
}
// Check if the file has the correct extension.
if !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 := strings.TrimSuffix(relPath, "."+opts.TemplateExtension)
nest.cache[templateName] = templateIndex
return nil
})
if err != nil {
return nil, err
}
return nest, nil
}
2024-11-16 08:08:22 +00:00
// 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 {
for k, v := range FlattenMap(nestedMap, fullKey, separator) {
result[k] = v
}
} else {
result[fullKey] = value
}
}
return result
}
2024-11-16 04:13:48 +00:00
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)
2024-11-16 04:13:48 +00:00
}
2024-11-16 05:41:07 +00:00
2024-11-16 04:13:48 +00:00
// Capture last modified time
fileInfo, err := os.Stat(filePath)
if err != nil {
return TemplateFileIndex{}, fmt.Errorf("error getting file (`%s`) info: %w", filePath, err)
2024-11-16 04:13:48 +00:00
}
variableNames := make(map[string]struct{})
variables := []TemplateFileVariable{}
delimiterStart := nest.option.Delimiters[0]
delimiterEnd := nest.option.Delimiters[1]
2024-11-16 06:18:50 +00:00
escapeChar := nest.option.TokenEscapeChar
2024-11-16 04:13:48 +00:00
re := regexp.MustCompile(fmt.Sprintf(
"%s\\s*(.+?)\\s*%s", regexp.QuoteMeta(delimiterStart), regexp.QuoteMeta(delimiterEnd),
))
2024-11-16 05:41:07 +00:00
contentsStr := string(contents)
2024-11-16 04:13:48 +00:00
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{}{}
2024-11-16 06:18:50 +00:00
// 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
}
}
2024-11-16 05:41:07 +00:00
// 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
}
}
2024-11-16 04:13:48 +00:00
variables = append(variables, TemplateFileVariable{
Name: varName,
StartPosition: uint(startIdx),
EndPosition: uint(endIdx),
2024-11-16 05:41:07 +00:00
IndentLevel: uint(indentLevel),
2024-11-16 04:13:48 +00:00
})
}
fileIndex := TemplateFileIndex{
2024-11-16 05:41:07 +00:00
Contents: contentsStr,
2024-11-16 04:13:48 +00:00
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 {
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) {
2024-11-16 12:50:20 +00:00
if reflect.TypeOf(toRender).Kind() == reflect.Slice {
return nest.renderSlice(toRender)
}
2024-11-16 04:13:48 +00:00
switch v := toRender.(type) {
case nil:
return "", nil
case bool:
return fmt.Sprintf("%t", v), nil
case string:
2024-11-16 12:03:15 +00:00
if nest.option.NoEscapeInput {
return v, nil
}
return html.EscapeString(v), nil
2024-11-16 04:13:48 +00:00
case float64, int, int64:
return fmt.Sprintf("%v", v), nil
case Hash:
2024-11-16 12:50:20 +00:00
return nest.renderHash(v)
2024-11-16 04:13:48 +00:00
2024-11-16 12:50:20 +00:00
case map[string]interface{}:
return nest.renderHash(v)
2024-11-16 04:13:48 +00:00
2024-11-16 12:50:20 +00:00
default:
return "", fmt.Errorf("unsupported template hash value type: %+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)
2024-11-16 04:13:48 +00:00
if err != nil {
2024-11-16 12:50:20 +00:00
return "", err
2024-11-16 04:13:48 +00:00
}
2024-11-16 12:50:20 +00:00
rendered.WriteString(subRender)
}
return rendered.String(), nil
}
2024-11-16 04:13:48 +00:00
2024-11-16 12:50:20 +00:00
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,
)
}
2024-11-16 04:13:48 +00:00
2024-11-16 12:50:20 +00:00
tName, ok := tLabel.(string)
if !ok {
return "", fmt.Errorf(
"encountered hash with invalid name label type: %+v", tLabel,
)
}
2024-11-16 04:13:48 +00:00
2024-11-16 12:50:20 +00:00
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)
}
2024-11-16 12:50:20 +00:00
tIndex = newIndex
}
2024-11-16 12:50:20 +00:00
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,
)
2024-11-16 06:18:50 +00:00
}
2024-11-16 12:50:20 +00:00
}
}
2024-11-16 04:13:48 +00:00
2024-11-16 12:50:20 +00:00
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
}
2024-11-16 08:08:22 +00:00
2024-11-16 12:50:20 +00:00
// If the variable doesn't exist in template hash then replace it
// with an empty string.
replacement := ""
2024-11-16 08:08:22 +00:00
2024-11-16 12:50:20 +00:00
value, exists := hash[variable.Name]
defaultValue, defaultExists := nest.defaultsFlat[variable.Name]
2024-11-16 05:41:07 +00:00
2024-11-16 12:50:20 +00:00
if exists || defaultExists {
if !exists {
value = defaultValue
}
2024-11-16 05:41:07 +00:00
2024-11-16 12:50:20 +00:00
subRender, err := nest.Render(value)
if err != nil {
return "", err
2024-11-16 04:13:48 +00:00
}
2024-11-16 12:50:20 +00:00
if nest.option.FixedIndent && variable.IndentLevel != 0 {
indentReplacement := "\n" + strings.Repeat(" ", int(variable.IndentLevel))
subRender = strings.ReplaceAll(subRender, "\n", indentReplacement)
}
2024-11-16 04:13:48 +00:00
2024-11-16 12:50:20 +00:00
replacement = subRender
2024-11-16 05:10:45 +00:00
}
2024-11-16 12:50:20 +00:00
// Replace in rendered template
rendered = rendered[:variable.StartPosition] + replacement + rendered[variable.EndPosition:]
}
2024-11-16 04:13:48 +00:00
2024-11-16 12:50:20 +00:00
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
2024-11-16 04:13:48 +00:00
}
2024-11-16 12:50:20 +00:00
return strings.TrimSpace(rendered), nil
2024-11-16 04:13:48 +00:00
}