// SPDX-License-Identifier: AGPL-3.0-or-later // Package render provides HTML template rendering with layout support. package render import ( "fmt" "html/template" "io" "io/fs" "log/slog" "net/http" "os" "path/filepath" "strings" "kode.naiv.no/olemd/favoritter/internal/config" "kode.naiv.no/olemd/favoritter/internal/middleware" "kode.naiv.no/olemd/favoritter/internal/model" "kode.naiv.no/olemd/favoritter/internal/store" "kode.naiv.no/olemd/favoritter/web" ) // Renderer parses and executes HTML templates. type Renderer struct { templates map[string]*template.Template cfg *config.Config } // PageData holds data passed to page templates. type PageData struct { Title string User *model.User CSRFToken string BasePath string SiteName string Flash string FlashType string // "success", "error", "info" ExternalURL string Data any } // New creates a Renderer, parsing all templates from the embedded filesystem. func New(cfg *config.Config) (*Renderer, error) { r := &Renderer{ cfg: cfg, } if err := r.parseTemplates(); err != nil { return nil, err } return r, nil } func (r *Renderer) parseTemplates() error { r.templates = make(map[string]*template.Template) var templateFS fs.FS if r.cfg.DevMode { // In dev mode, read templates from disk for live reload. templateFS = os.DirFS(filepath.Join("web", "templates")) } else { sub, err := fs.Sub(web.TemplatesFS, "templates") if err != nil { return fmt.Errorf("sub templates fs: %w", err) } templateFS = sub } funcMap := r.templateFuncs() // Parse the base layout. baseLayout, err := template.New("base.html").Funcs(funcMap).ParseFS(templateFS, "layouts/base.html") if err != nil { return fmt.Errorf("parse base layout: %w", err) } // Parse each page template with the base layout. pages, err := fs.Glob(templateFS, "pages/*.html") if err != nil { return fmt.Errorf("glob pages: %w", err) } for _, page := range pages { name := strings.TrimPrefix(page, "pages/") name = strings.TrimSuffix(name, ".html") t, err := baseLayout.Clone() if err != nil { return fmt.Errorf("clone base for %s: %w", name, err) } _, err = t.ParseFS(templateFS, page) if err != nil { return fmt.Errorf("parse page %s: %w", name, err) } r.templates[name] = t } // Parse partial templates (for HTMX responses). partials, err := fs.Glob(templateFS, "partials/*.html") if err != nil { return fmt.Errorf("glob partials: %w", err) } for _, partial := range partials { name := "partial:" + strings.TrimPrefix(partial, "partials/") name = strings.TrimSuffix(name, ".html") t, err := template.New(filepath.Base(partial)).Funcs(funcMap).ParseFS(templateFS, partial) if err != nil { return fmt.Errorf("parse partial %s: %w", name, err) } r.templates[name] = t } slog.Info("templates loaded", "pages", len(pages), "partials", len(partials)) return nil } // Page renders a full page with the base layout. func (r *Renderer) Page(w http.ResponseWriter, req *http.Request, name string, data PageData) { if r.cfg.DevMode { // Reparse templates on every request in dev mode. if err := r.parseTemplates(); err != nil { slog.Error("template reparse failed", "error", err) http.Error(w, "Template error", http.StatusInternalServerError) return } } // Populate common data from context. data.User = middleware.UserFromContext(req.Context()) data.CSRFToken = middleware.CSRFTokenFromContext(req.Context()) data.BasePath = r.cfg.BasePath data.SiteName = r.cfg.SiteName data.ExternalURL = r.cfg.ExternalURL t, ok := r.templates[name] if !ok { slog.Error("template not found", "name", name) http.Error(w, "Template not found", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.ExecuteTemplate(w, "base.html", data); err != nil { slog.Error("template execute failed", "name", name, "error", err) } } // Error renders an error page with the given HTTP status code. func (r *Renderer) Error(w http.ResponseWriter, req *http.Request, code int, message string) { data := PageData{ Title: message, Data: map[string]any{ "Code": code, "Message": message, }, } // Populate common fields from context (same as Page does). data.User = middleware.UserFromContext(req.Context()) data.CSRFToken = middleware.CSRFTokenFromContext(req.Context()) data.BasePath = r.cfg.BasePath data.SiteName = r.cfg.SiteName data.ExternalURL = r.cfg.ExternalURL t, ok := r.templates["error"] if !ok { http.Error(w, message, code) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(code) if err := t.ExecuteTemplate(w, "base.html", data); err != nil { slog.Error("error template execute failed", "error", err) } } // Partial renders a partial template (for HTMX responses). func (r *Renderer) Partial(w io.Writer, name string, data any) error { key := "partial:" + name t, ok := r.templates[key] if !ok { return fmt.Errorf("partial template %q not found", name) } return t.Execute(w, data) } func (r *Renderer) templateFuncs() template.FuncMap { return template.FuncMap{ // basePath returns the configured base path for URL construction. "basePath": func() string { return r.cfg.BasePath }, // externalURL returns the configured external URL. "externalURL": func() string { return r.cfg.ExternalURL }, // truncate truncates a string to n runes (not bytes) to avoid // splitting multi-byte UTF-8 characters (Norwegian æøå etc.). "truncate": func(n int, s string) string { runes := []rune(s) if len(runes) <= n { return s } return string(runes[:n]) + "..." }, // join joins strings with a separator. "join": strings.Join, // contains checks if a string contains a substring. "contains": strings.Contains, // add returns a + b. "add": func(a, b int) int { return a + b }, // subtract returns a - b. "subtract": func(a, b int) int { return a - b }, // maxTags returns the maximum number of tags per fave. "maxTags": func() int { return store.MaxTagsPerFave }, } }