Files
canon/internal/server/render.go
T
cdnutterandClaude Opus 5.5 3923444b3b Rename project from TopDeck to Canon
- Module github.com/cdnutter/canon, binary/command cmd/canon, Docker
  image and compose project "canon"
- Environment variables now use the CANON_ prefix
- Default site title is "<Your name>'s Canon" (or "Canon" with no name);
  a Site title set in Settings still overrides it
- Existing topdeck.db (and WAL files) is renamed to canon.db on start
- README: upgrade notes, including moving the old compose volume

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:22:17 -07:00

203 lines
4.9 KiB
Go

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
}
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",
}
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(`<svg class="icon" aria-hidden="true"><use href="/static/img/icons.svg#%s"></use></svg>`, 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,
// 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)
}