package server import ( "bytes" "context" "fmt" "html/template" "io/fs" "net/http" "path" "strconv" "strings" "github.com/cdnutter/canon/internal/store" "github.com/cdnutter/canon/web" ) // Site is the owner-configurable identity shown on every page. type Site struct { Title string OwnerName string Tagline string AvatarURL string Theme string CustomCSS bool CSSVersion string PreviewCount int ShowNotes bool ShowPreviews bool } type pageData struct { Site Site PageTitle string LoggedIn bool Username string Path string AssetVer string Data any } func (s *Server) site(ctx context.Context) Site { set, err := s.store.Settings(ctx) if err != nil { s.log.Error("loading settings", "err", err) set = map[string]string{} } site := Site{ Title: set[store.SettingSiteTitle], OwnerName: set[store.SettingOwnerName], Tagline: set[store.SettingTagline], Theme: set[store.SettingTheme], PreviewCount: 10, ShowNotes: set[store.SettingShowNotes] != "false", ShowPreviews: set[store.SettingShowPreviews] != "false", } if site.Title == "" { site.Title = DefaultTitle(site.OwnerName) } if !validTheme(site.Theme) { site.Theme = DefaultTheme } if n, err := strconv.Atoi(set[store.SettingPreviewCount]); err == nil && n >= 0 && n <= 50 { site.PreviewCount = n } if a := set[store.SettingAvatar]; a != "" { site.AvatarURL = "/images/" + a } if css := strings.TrimSpace(set[store.SettingCustomCSS]); css != "" { site.CustomCSS = true site.CSSVersion = shortHash(css) } return site } // DefaultTitle is the site title when the owner hasn't set one: // "Chris's Canon", or just "Canon" before they've entered a name. func DefaultTitle(ownerName string) string { if ownerName = strings.TrimSpace(ownerName); ownerName == "" { return "Canon" } return ownerName + "'s Canon" } func (s *Server) handleCustomCSS(w http.ResponseWriter, r *http.Request) { set, err := s.store.Settings(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=300") w.Write([]byte(set[store.SettingCustomCSS])) } var funcs = template.FuncMap{ "icon": func(name string) template.HTML { return template.HTML(fmt.Sprintf(``, template.HTMLEscapeString(name))) }, "category": func(key string) store.Category { c, _ := store.CategoryByKey(key) return c }, "add": func(a, b int) int { return a + b }, "hasPrefix": strings.HasPrefix, // first and after split a ranked list: the podium/spotlight entries and // everything below them. "first": func(n int, items []ItemView) []ItemView { return items[:min(n, len(items))] }, "after": func(n int, items []ItemView) []ItemView { return items[min(n, len(items)):] }, // flatLayout is how a style shows entries that aren't featured. "flatLayout": func(style string) string { if style == "grid" { return "grid" } return "rows" }, // dict builds a map for passing several values to a partial. "dict": func(kv ...any) map[string]any { m := map[string]any{} for i := 0; i+1 < len(kv); i += 2 { m[kv[i].(string)] = kv[i+1] } return m }, "num": formatNumber, "plural": func(n int, one, many string) string { if n == 1 { return one } return many }, "initial": func(s string) string { for _, r := range strings.TrimSpace(s) { return strings.ToUpper(string(r)) } return "?" }, "pct": func(a, b int) int { if b <= 0 { return 0 } p := a * 100 / b if a > 0 && p == 0 { p = 1 } return p }, } func formatNumber(n int) string { s := strconv.Itoa(n) if n < 0 { return "-" + formatNumber(-n) } var b strings.Builder for i, c := range s { if i > 0 && (len(s)-i)%3 == 0 { b.WriteByte(',') } b.WriteRune(c) } return b.String() } // parseTemplates builds one template set per page, each combining the base // layout, shared partials and the page itself. func (s *Server) parseTemplates() error { shared, err := fs.Glob(web.FS, "templates/partials/*.html") if err != nil { return err } pages, err := fs.Glob(web.FS, "templates/pages/*.html") if err != nil { return err } s.pages = map[string]*template.Template{} for _, p := range pages { files := append([]string{"templates/layout.html"}, shared...) files = append(files, p) t, err := template.New("layout.html").Funcs(funcs).ParseFS(web.FS, files...) if err != nil { return fmt.Errorf("parsing %s: %w", p, err) } s.pages[strings.TrimSuffix(path.Base(p), ".html")] = t } return nil } func (s *Server) render(w http.ResponseWriter, r *http.Request, status int, page, title string, data any) { t, ok := s.pages[page] if !ok { http.Error(w, "missing template "+page, http.StatusInternalServerError) return } pd := pageData{ Site: s.site(r.Context()), PageTitle: title, Path: r.URL.Path, AssetVer: s.assetVer, Data: data, } if u, ok := s.currentUser(r); ok { pd.LoggedIn, pd.Username = true, u.Username } var buf bytes.Buffer if err := t.Execute(&buf, pd); err != nil { s.log.Error("rendering", "page", page, "err", err) http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.WriteHeader(status) buf.WriteTo(w) }