2025-10-20 08:54:21 +01:00
|
|
|
package templates
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"html/template"
|
|
|
|
|
"log"
|
|
|
|
|
"path/filepath"
|
2026-04-27 00:32:54 +01:00
|
|
|
"strings"
|
2025-10-20 08:54:21 +01:00
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-02 13:49:54 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-20 08:54:21 +01:00
|
|
|
// 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()
|
2025-11-30 09:29:35 +00:00
|
|
|
return m.loadTemplatesLocked()
|
|
|
|
|
}
|
2025-10-20 08:54:21 +01:00
|
|
|
|
2025-11-30 09:29:35 +00:00
|
|
|
// loadTemplatesLocked parses templates without acquiring lock (caller must hold lock)
|
|
|
|
|
func (m *Manager) loadTemplatesLocked() error {
|
2025-10-20 08:54:21 +01:00
|
|
|
// 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
|
|
|
|
|
},
|
2025-11-12 11:52:52 +00:00
|
|
|
// 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)
|
|
|
|
|
},
|
2026-04-27 00:32:54 +01:00
|
|
|
// replaceDrolosoft links "drolosoft" in the summary text
|
|
|
|
|
"replaceDrolosoft": func(s string) string {
|
|
|
|
|
return strings.Replace(s, "drolosoft", `<a href="https://drolosoft.com" target="_blank" rel="noopener noreferrer">drolosoft</a>`, 1)
|
|
|
|
|
},
|
2025-11-12 18:26:18 +00:00
|
|
|
// 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
|
|
|
|
|
},
|
2025-10-20 08:54:21 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-12 18:26:18 +00:00
|
|
|
// 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...)
|
2025-10-20 08:54:21 +01:00
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("Warning: error parsing partials: %v", err)
|
2025-11-12 18:26:18 +00:00
|
|
|
} else {
|
2025-11-20 16:52:30 +00:00
|
|
|
log.Printf("📦 Loaded %d partial templates", len(allPartials))
|
2025-10-20 08:54:21 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m.templates = tmpl
|
2025-11-20 16:52:30 +00:00
|
|
|
log.Printf("📋 Templates loaded successfully from %s", m.config.Dir)
|
2025-10-20 08:54:21 +01:00
|
|
|
|
|
|
|
|
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
|
2025-11-30 09:29:35 +00:00
|
|
|
// Note: This method is thread-safe. Hot reload acquires full lock to prevent race conditions.
|
2025-10-20 08:54:21 +01:00
|
|
|
func (m *Manager) Render(name string) (*template.Template, error) {
|
|
|
|
|
// Hot reload in development mode
|
2025-11-30 09:29:35 +00:00
|
|
|
// Use full lock to prevent race condition between reload and lookup
|
2025-10-20 08:54:21 +01:00
|
|
|
if m.config.HotReload {
|
2025-11-30 09:29:35 +00:00
|
|
|
m.mu.Lock()
|
|
|
|
|
if err := m.loadTemplatesLocked(); err != nil {
|
|
|
|
|
m.mu.Unlock()
|
2025-10-20 08:54:21 +01:00
|
|
|
log.Printf("Warning: template reload failed: %v", err)
|
2025-11-30 09:29:35 +00:00
|
|
|
// 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)
|
2025-10-20 08:54:21 +01:00
|
|
|
}
|
2025-11-30 09:29:35 +00:00
|
|
|
return tmpl, nil
|
2025-10-20 08:54:21 +01:00
|
|
|
}
|
|
|
|
|
|
2025-11-30 09:29:35 +00:00
|
|
|
// Production mode: just read
|
2025-10-20 08:54:21 +01:00
|
|
|
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
|
|
|
|
|
}
|