package templatenest import ( "fmt" "html" "io/ioutil" "os" "path/filepath" "reflect" "regexp" "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 float64, int, int64: return fmt.Sprintf("%v", v), nil case Hash: return nest.renderHash(v) case map[string]interface{}: return nest.renderHash(v) 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) 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 }