package templates import ( "fmt" "html/template" "log" "path/filepath" "strings" "sync" "github.com/juanatsap/cv-site/internal/config" ) // Manager handles template parsing and rendering type Manager struct { templates *template.Template config *config.TemplateConfig mu sync.RWMutex } // IsInitialized returns true if the template manager has been properly initialized // with a config. Empty Manager structs (e.g., in tests) will return false. func (m *Manager) IsInitialized() bool { return m != nil && m.config != nil } // NewManager creates a new template manager func NewManager(cfg *config.TemplateConfig) (*Manager, error) { m := &Manager{ config: cfg, } if err := m.loadTemplates(); err != nil { return nil, fmt.Errorf("failed to load templates: %w", err) } return m, nil } // loadTemplates parses all templates from the configured directory func (m *Manager) loadTemplates() error { m.mu.Lock() defer m.mu.Unlock() return m.loadTemplatesLocked() } // loadTemplatesLocked parses templates without acquiring lock (caller must hold lock) func (m *Manager) loadTemplatesLocked() error { // Create template with custom functions funcMap := template.FuncMap{ "iterate": func(count int) []int { var result []int for i := 0; i < count; i++ { result = append(result, i) } return result }, "eq": func(a, b string) bool { return a == b }, // safeHTML marks string as safe HTML to prevent escaping // SECURITY NOTE: Only use with trusted content from CV YAML files // Never use with user-generated content to prevent XSS attacks "safeHTML": func(s string) template.HTML { return template.HTML(s) }, // replaceDrolosoft links "drolosoft" in the summary text "replaceDrolosoft": func(s string) string { return strings.Replace(s, "drolosoft", `drolosoft`, 1) }, // dict creates a map from key-value pairs for passing to sub-templates "dict": func(values ...interface{}) (map[string]interface{}, error) { if len(values)%2 != 0 { return nil, fmt.Errorf("dict requires even number of arguments") } dict := make(map[string]interface{}, len(values)/2) for i := 0; i < len(values); i += 2 { key, ok := values[i].(string) if !ok { return nil, fmt.Errorf("dict keys must be strings") } dict[key] = values[i+1] } return dict, nil }, } // Parse main templates pattern := filepath.Join(m.config.Dir, "*.html") tmpl, err := template.New("").Funcs(funcMap).ParseGlob(pattern) if err != nil { return fmt.Errorf("error parsing templates from %s: %w", pattern, err) } // Parse partials recursively from all subdirectories partialsPattern := filepath.Join(m.config.PartialsDir, "**", "*.html") partialsMatches, _ := filepath.Glob(partialsPattern) // Also match direct children partialsDirectPattern := filepath.Join(m.config.PartialsDir, "*.html") directMatches, _ := filepath.Glob(partialsDirectPattern) // Combine all matches allPartials := append(partialsMatches, directMatches...) if len(allPartials) > 0 { tmpl, err = tmpl.ParseFiles(allPartials...) if err != nil { log.Printf("Warning: error parsing partials: %v", err) } else { log.Printf("📦 Loaded %d partial templates", len(allPartials)) } } m.templates = tmpl log.Printf("📋 Templates loaded successfully from %s", m.config.Dir) return nil } // Reload reloads all templates (useful for hot-reload in development) func (m *Manager) Reload() error { return m.loadTemplates() } // Render executes a template with the given data // Note: This method is thread-safe. Hot reload acquires full lock to prevent race conditions. func (m *Manager) Render(name string) (*template.Template, error) { // Hot reload in development mode // Use full lock to prevent race condition between reload and lookup if m.config.HotReload { m.mu.Lock() if err := m.loadTemplatesLocked(); err != nil { m.mu.Unlock() log.Printf("Warning: template reload failed: %v", err) // Fall back to read lock for cached templates m.mu.RLock() defer m.mu.RUnlock() tmpl := m.templates.Lookup(name) if tmpl == nil { return nil, fmt.Errorf("template %q not found", name) } return tmpl, nil } tmpl := m.templates.Lookup(name) m.mu.Unlock() if tmpl == nil { return nil, fmt.Errorf("template %q not found", name) } return tmpl, nil } // Production mode: just read m.mu.RLock() defer m.mu.RUnlock() tmpl := m.templates.Lookup(name) if tmpl == nil { return nil, fmt.Errorf("template %q not found", name) } return tmpl, nil }