internal/templates/render.go
283 Zeilen · 6.5 KB · Go
| 1 | package templates |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "html/template" |
| 7 | "io" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/ruedigerp/newblog/internal/config" |
| 14 | "github.com/ruedigerp/newblog/internal/content" |
| 15 | ) |
| 16 | |
| 17 | type SiteData struct { |
| 18 | Title string |
| 19 | Description string |
| 20 | Author string |
| 21 | BaseURL string |
| 22 | Menu []config.MenuItem |
| 23 | } |
| 24 | |
| 25 | type HomeData struct { |
| 26 | Site SiteData |
| 27 | Posts []*content.Post |
| 28 | Page int // current page (1-based) |
| 29 | TotalPages int |
| 30 | } |
| 31 | |
| 32 | type PostData struct { |
| 33 | Site SiteData |
| 34 | Post *content.Post |
| 35 | PrevPost *content.Post // newer post (nil if this is the newest) |
| 36 | NextPost *content.Post // older post (nil if this is the oldest) |
| 37 | } |
| 38 | |
| 39 | type TagsData struct { |
| 40 | Site SiteData |
| 41 | Tags map[string]int |
| 42 | } |
| 43 | |
| 44 | type TagData struct { |
| 45 | Site SiteData |
| 46 | Tag string |
| 47 | Posts []*content.Post |
| 48 | } |
| 49 | |
| 50 | type CategoriesData struct { |
| 51 | Site SiteData |
| 52 | Categories map[string]int |
| 53 | } |
| 54 | |
| 55 | type CategoryData struct { |
| 56 | Site SiteData |
| 57 | Category string |
| 58 | Posts []*content.Post |
| 59 | } |
| 60 | |
| 61 | type PageData struct { |
| 62 | Site SiteData |
| 63 | Page *content.Page |
| 64 | } |
| 65 | |
| 66 | var funcMap = template.FuncMap{ |
| 67 | "formatDate": func(t time.Time) string { |
| 68 | months := []string{ |
| 69 | "", "Januar", "Februar", "März", "April", "Mai", "Juni", |
| 70 | "Juli", "August", "September", "Oktober", "November", "Dezember", |
| 71 | } |
| 72 | return t.Format("02.") + " " + months[t.Month()] + " " + t.Format("2006") |
| 73 | }, |
| 74 | "isoDate": func(t time.Time) string { |
| 75 | return t.Format("2006-01-02") |
| 76 | }, |
| 77 | "rssDate": func(t time.Time) string { |
| 78 | return t.Format(time.RFC1123Z) |
| 79 | }, |
| 80 | "lower": strings.ToLower, |
| 81 | "safeHTML": func(s template.HTML) template.HTML { |
| 82 | return s |
| 83 | }, |
| 84 | "hasChildren": func(item config.MenuItem) bool { |
| 85 | return len(item.Children) > 0 |
| 86 | }, |
| 87 | "add": func(a, b int) int { |
| 88 | return a + b |
| 89 | }, |
| 90 | "sub": func(a, b int) int { |
| 91 | return a - b |
| 92 | }, |
| 93 | "pageURL": func(page int) string { |
| 94 | if page <= 1 { |
| 95 | return "/" |
| 96 | } |
| 97 | return fmt.Sprintf("/page/%d/", page) |
| 98 | }, |
| 99 | "seq": func(from, to int) []int { |
| 100 | var s []int |
| 101 | for i := from; i <= to; i++ { |
| 102 | s = append(s, i) |
| 103 | } |
| 104 | return s |
| 105 | }, |
| 106 | // paginationRange returns page numbers to display, with -1 as ellipsis placeholder. |
| 107 | // Shows: first, last, and a window of 2 around the current page. |
| 108 | "paginationRange": func(current, total int) []int { |
| 109 | if total <= 7 { |
| 110 | // Show all pages if 7 or fewer |
| 111 | var s []int |
| 112 | for i := 1; i <= total; i++ { |
| 113 | s = append(s, i) |
| 114 | } |
| 115 | return s |
| 116 | } |
| 117 | pages := make(map[int]bool) |
| 118 | pages[1] = true |
| 119 | pages[total] = true |
| 120 | for i := current - 2; i <= current+2; i++ { |
| 121 | if i >= 1 && i <= total { |
| 122 | pages[i] = true |
| 123 | } |
| 124 | } |
| 125 | var result []int |
| 126 | prev := 0 |
| 127 | for i := 1; i <= total; i++ { |
| 128 | if pages[i] { |
| 129 | if prev > 0 && i-prev > 1 { |
| 130 | result = append(result, -1) // ellipsis |
| 131 | } |
| 132 | result = append(result, i) |
| 133 | prev = i |
| 134 | } |
| 135 | } |
| 136 | return result |
| 137 | }, |
| 138 | "isEllipsis": func(n int) bool { |
| 139 | return n == -1 |
| 140 | }, |
| 141 | } |
| 142 | |
| 143 | var ( |
| 144 | homeTmpl *view |
| 145 | postTmpl *view |
| 146 | tagsTmpl *view |
| 147 | tagTmpl *view |
| 148 | categoriesTmpl *view |
| 149 | categoryTmpl *view |
| 150 | pageTmpl *view |
| 151 | ) |
| 152 | |
| 153 | // templateDir holds the directory the templates of the active theme were |
| 154 | // loaded from (e.g. "themes/default/templates"). |
| 155 | var templateDir string |
| 156 | |
| 157 | // devMode re-parses templates on every render, so theme edits become visible |
| 158 | // without restarting the server. |
| 159 | var devMode bool |
| 160 | |
| 161 | // SetDevMode enables or disables template live reload. Must be called before |
| 162 | // serving requests. |
| 163 | func SetDevMode(enabled bool) { |
| 164 | devMode = enabled |
| 165 | } |
| 166 | |
| 167 | // DevMode reports whether template live reload is enabled. |
| 168 | func DevMode() bool { |
| 169 | return devMode |
| 170 | } |
| 171 | |
| 172 | // view is a page template together with the theme files it was built from, |
| 173 | // so it can be re-parsed on demand in dev mode. |
| 174 | type view struct { |
| 175 | files []string |
| 176 | tmpl *template.Template |
| 177 | } |
| 178 | |
| 179 | func newView(files ...string) (*view, error) { |
| 180 | tmpl, err := parseTemplate(files...) |
| 181 | if err != nil { |
| 182 | return nil, err |
| 183 | } |
| 184 | return &view{files: files, tmpl: tmpl}, nil |
| 185 | } |
| 186 | |
| 187 | func (v *view) execute(w io.Writer, data any) error { |
| 188 | if !devMode { |
| 189 | return v.tmpl.ExecuteTemplate(w, "base", data) |
| 190 | } |
| 191 | // Live reload: parse the theme files again and render into a buffer first, |
| 192 | // so a broken template does not emit half a page. |
| 193 | tmpl, err := parseTemplate(v.files...) |
| 194 | if err != nil { |
| 195 | return err |
| 196 | } |
| 197 | var buf bytes.Buffer |
| 198 | if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil { |
| 199 | return err |
| 200 | } |
| 201 | _, err = w.Write(buf.Bytes()) |
| 202 | return err |
| 203 | } |
| 204 | |
| 205 | func parseTemplate(files ...string) (*template.Template, error) { |
| 206 | paths := make([]string, 0, len(files)) |
| 207 | for _, f := range files { |
| 208 | path := filepath.Join(templateDir, f) |
| 209 | if _, err := os.Stat(path); err != nil { |
| 210 | return nil, fmt.Errorf("missing template %s in theme: %w", f, err) |
| 211 | } |
| 212 | paths = append(paths, path) |
| 213 | } |
| 214 | return template.New("").Funcs(funcMap).ParseFiles(paths...) |
| 215 | } |
| 216 | |
| 217 | // Init loads all templates of the theme located in themeDir |
| 218 | // (e.g. "themes/default"). The templates are expected in themeDir/templates. |
| 219 | func Init(themeDir string) error { |
| 220 | templateDir = filepath.Join(themeDir, "templates") |
| 221 | info, err := os.Stat(templateDir) |
| 222 | if err != nil || !info.IsDir() { |
| 223 | return fmt.Errorf("theme templates not found in %s (check 'theme' and 'themesDir' in config.yaml)", templateDir) |
| 224 | } |
| 225 | |
| 226 | homeTmpl, err = newView("base.html", "home.html") |
| 227 | if err != nil { |
| 228 | return fmt.Errorf("home template: %w", err) |
| 229 | } |
| 230 | postTmpl, err = newView("base.html", "post.html") |
| 231 | if err != nil { |
| 232 | return fmt.Errorf("post template: %w", err) |
| 233 | } |
| 234 | tagsTmpl, err = newView("base.html", "tags.html") |
| 235 | if err != nil { |
| 236 | return fmt.Errorf("tags template: %w", err) |
| 237 | } |
| 238 | tagTmpl, err = newView("base.html", "tag.html") |
| 239 | if err != nil { |
| 240 | return fmt.Errorf("tag template: %w", err) |
| 241 | } |
| 242 | categoriesTmpl, err = newView("base.html", "categories.html") |
| 243 | if err != nil { |
| 244 | return fmt.Errorf("categories template: %w", err) |
| 245 | } |
| 246 | categoryTmpl, err = newView("base.html", "category.html") |
| 247 | if err != nil { |
| 248 | return fmt.Errorf("category template: %w", err) |
| 249 | } |
| 250 | pageTmpl, err = newView("base.html", "page.html") |
| 251 | if err != nil { |
| 252 | return fmt.Errorf("page template: %w", err) |
| 253 | } |
| 254 | return nil |
| 255 | } |
| 256 | |
| 257 | func RenderHome(w io.Writer, data HomeData) error { |
| 258 | return homeTmpl.execute(w, data) |
| 259 | } |
| 260 | |
| 261 | func RenderPost(w io.Writer, data PostData) error { |
| 262 | return postTmpl.execute(w, data) |
| 263 | } |
| 264 | |
| 265 | func RenderTags(w io.Writer, data TagsData) error { |
| 266 | return tagsTmpl.execute(w, data) |
| 267 | } |
| 268 | |
| 269 | func RenderTag(w io.Writer, data TagData) error { |
| 270 | return tagTmpl.execute(w, data) |
| 271 | } |
| 272 | |
| 273 | func RenderCategories(w io.Writer, data CategoriesData) error { |
| 274 | return categoriesTmpl.execute(w, data) |
| 275 | } |
| 276 | |
| 277 | func RenderCategory(w io.Writer, data CategoryData) error { |
| 278 | return categoryTmpl.execute(w, data) |
| 279 | } |
| 280 | |
| 281 | func RenderPage(w io.Writer, data PageData) error { |
| 282 | return pageTmpl.execute(w, data) |
| 283 | } |