aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/templates.go
diff options
context:
space:
mode:
authorgramanas <grm@eyesin.space>2026-09-12 11:24:17 +0300
committergramanas <grm@eyesin.space>2026-09-12 11:24:17 +0300
commit3eb04b1a2bdf9e53231fe862cfd76327371a9741 (patch)
treeb38b2d82a47233fd8e0bb18c59e4a8f3dd2412d7 /internal/web/templates.go
downloadblogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.gz
blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.bz2
blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.zip
Initial multi-tenant blog host
Go + Postgres application serving a management dashboard on the base domain and one public blog per subdomain. Markdown posts organised in pages, form-based theme customisation, image uploads stored in Postgres, JWT cookie sessions with CSRF, superadmin user management, RSS feeds. Docker/compose deployment and a Makefile-driven dev environment with seed data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/templates.go')
-rw-r--r--internal/web/templates.go117
1 files changed, 117 insertions, 0 deletions
diff --git a/internal/web/templates.go b/internal/web/templates.go
new file mode 100644
index 0000000..f322ccc
--- /dev/null
+++ b/internal/web/templates.go
@@ -0,0 +1,117 @@
+package web
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+)
+
+//go:embed templates static
+var assets embed.FS
+
+// templates compiles each page together with its layout. In dev mode it
+// re-reads from disk on every render so edits show up without a restart.
+type templates struct {
+ dev bool
+ fs fs.FS
+ funcs template.FuncMap
+ mu sync.Mutex
+ cache map[string]*template.Template
+}
+
+func newTemplates(dev bool, funcs template.FuncMap) *templates {
+ t := &templates{dev: dev, funcs: funcs, cache: map[string]*template.Template{}}
+ t.fs = assets
+ if dev {
+ // locate the package directory so `go run` from anywhere still finds the files
+ _, file, _, _ := runtime.Caller(0)
+ dir := filepath.Dir(file)
+ if _, err := os.Stat(filepath.Join(dir, "templates")); err == nil {
+ t.fs = os.DirFS(dir)
+ }
+ }
+ return t
+}
+
+// AssetsFS returns the static files (embedded or on disk in dev mode).
+func (t *templates) AssetsFS() fs.FS {
+ sub, _ := fs.Sub(t.fs, "static")
+ return sub
+}
+
+func layoutFor(name string) string {
+ if strings.HasPrefix(name, "blog/") {
+ return "layouts/blog.html"
+ }
+ return "layouts/dashboard.html"
+}
+
+func (t *templates) get(name string) (*template.Template, error) {
+ if !t.dev {
+ t.mu.Lock()
+ if tpl, ok := t.cache[name]; ok {
+ t.mu.Unlock()
+ return tpl, nil
+ }
+ t.mu.Unlock()
+ }
+ tpl, err := template.New("").Funcs(t.funcs).ParseFS(t.fs,
+ "templates/"+layoutFor(name), "templates/partials/*.html", "templates/"+name)
+ if err != nil {
+ return nil, fmt.Errorf("parse %s: %w", name, err)
+ }
+ if !t.dev {
+ t.mu.Lock()
+ t.cache[name] = tpl
+ t.mu.Unlock()
+ }
+ return tpl, nil
+}
+
+func (t *templates) render(w io.Writer, name string, data any) error {
+ tpl, err := t.get(name)
+ if err != nil {
+ return err
+ }
+ return tpl.ExecuteTemplate(w, "layout", data)
+}
+
+var funcs = template.FuncMap{
+ "date": func(t time.Time) string { return t.Format("2 January 2006") },
+ "rfc": func(t time.Time) string { return t.Format(time.RFC1123Z) },
+ "html": func(s string) template.HTML { return template.HTML(s) },
+ "css": func(s string) template.CSS { return template.CSS(s) },
+ "kb": func(n int) string { return fmt.Sprintf("%.0f KB", float64(n)/1024) },
+ "add": func(a, b int) int { return a + b },
+ "sub": func(a, b int) int { return a - b },
+ "deref": func(p *string) string {
+ if p == nil {
+ return ""
+ }
+ return *p
+ },
+ "lower": strings.ToLower,
+ // dict builds a map for passing several values to a partial: {{template "x" (dict "a" 1 "b" 2)}}
+ "dict": func(kv ...any) (map[string]any, error) {
+ if len(kv)%2 != 0 {
+ return nil, fmt.Errorf("dict: odd number of arguments")
+ }
+ m := make(map[string]any, len(kv)/2)
+ for i := 0; i < len(kv); i += 2 {
+ k, ok := kv[i].(string)
+ if !ok {
+ return nil, fmt.Errorf("dict: key %v is not a string", kv[i])
+ }
+ m[k] = kv[i+1]
+ }
+ return m, nil
+ },
+}