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 TemplateDir string TemplateExtension string DieOnBadParams bool } 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.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] re := regexp.MustCompile(fmt.Sprintf( "%s\\s*(.+?)\\s*%s", regexp.QuoteMeta(delimiterStart), regexp.QuoteMeta(delimiterEnd), )) 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{}{} variables = append(variables, TemplateFileVariable{ Name: varName, StartPosition: uint(startIdx), EndPosition: uint(endIdx), }) } fileIndex := TemplateFileIndex{ Contents: string(contents), 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("missing template label: %s", nest.option.NameLabel) } tName, ok := tLabel.(string) if !ok { return "", fmt.Errorf("invalid template label: %+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 } rendered := tIndex.Contents for i := len(tIndex.Variables) - 1; i >= 0; i-- { variable := tIndex.Variables[i] // If the variable doesn't exist in template hash then replace it // with an empty string. replacement := "" if value, exists := v[variable.Name]; exists { if text, ok := value.(string); ok { replacement = text } else { subRender, err := nest.Render(value) if err != nil { return "", err } replacement = subRender } } // Replace in rendered template rendered = rendered[:variable.StartPosition] + replacement + rendered[variable.EndPosition:] } return strings.TrimSpace(rendered), nil default: return "", fmt.Errorf("Unsupported template type: %+v", v) } }