Files
cv-site/internal/templates/template.go
T
juanatsap 9547bc7130 feat: remove Commando project, add Gotify Commander logo, drolosoft linked
- Remove unreleased Commando project from CV
- Add gotify-commander.png logo from GitHub repo
- "drolosoft" lowercase with link to drolosoft.com in summary
- Replace Certifications chip with Open source chip in chat
2026-04-27 00:32:54 +01:00

164 lines
4.5 KiB
Go

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", `<a href="https://drolosoft.com" target="_blank" rel="noopener noreferrer">drolosoft</a>`, 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
}