package templatenest import ( "fmt" "io/ioutil" "os" "path/filepath" "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 } type TemplateNest struct { option Option 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 == "" { opts.NameLabel = "TEMPLATE" } if opts.TemplateExtension == "" { opts.TemplateExtension = "html" } // Initialize TemplateNest with the final options. nest := &TemplateNest{ option: opts, cache: make(map[string]TemplateFileIndex), } // 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 } 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 { 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) { switch v := toRender.(type) { case nil: return "", nil case bool: return fmt.Sprintf("%t", v), nil case string: return v, nil case float64, int, int64: return fmt.Sprintf("%v", v), nil case []Hash: var rendered strings.Builder for _, item := range v { renderedItem, err := nest.Render(item) if err != nil { return "", err } rendered.WriteString(renderedItem) } return rendered.String(), nil case Hash: tLabel, ok := v[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 v { // 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 := "" if value, exists := v[variable.Name]; exists { 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 default: return "", fmt.Errorf("unsupported template hash value type: %+v", v) } }