internal/server/server.go
389 Zeilen · 11.5 KB · Go
| 1 | package server |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "log" |
| 7 | "net/http" |
| 8 | "os" |
| 9 | "path" |
| 10 | "path/filepath" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | |
| 14 | "github.com/ruedigerp/newblog/internal/config" |
| 15 | "github.com/ruedigerp/newblog/internal/content" |
| 16 | "github.com/ruedigerp/newblog/internal/generator" |
| 17 | "github.com/ruedigerp/newblog/internal/templates" |
| 18 | ) |
| 19 | |
| 20 | func Start(cfg *config.Config, posts []*content.Post, pages []*content.Page) { |
| 21 | if err := templates.Init(cfg.ThemeDir()); err != nil { |
| 22 | log.Fatalf("Failed to init templates: %v", err) |
| 23 | } |
| 24 | log.Printf("Using theme %q from %s", cfg.Theme, cfg.ThemeDir()) |
| 25 | |
| 26 | tagMap := content.CollectTags(posts) |
| 27 | catMap := content.CollectCategories(posts) |
| 28 | postMap := make(map[string]*content.Post) |
| 29 | postIndex := make(map[string]int) |
| 30 | for i, p := range posts { |
| 31 | postMap[p.Slug] = p |
| 32 | postIndex[p.Slug] = i |
| 33 | } |
| 34 | pageMap := make(map[string]*content.Page) |
| 35 | for _, p := range pages { |
| 36 | pageMap[p.Slug] = p |
| 37 | } |
| 38 | |
| 39 | site := templates.SiteData{ |
| 40 | Title: cfg.Title, |
| 41 | Description: cfg.Description, |
| 42 | Author: cfg.Author, |
| 43 | BaseURL: cfg.BaseURL, |
| 44 | Menu: cfg.Menu, |
| 45 | } |
| 46 | |
| 47 | mux := http.NewServeMux() |
| 48 | |
| 49 | // Static files: the site's static/ wins, the theme's static/ is the fallback. |
| 50 | // Mirrors the merge order used by the generator. |
| 51 | mux.Handle("/static/", http.StripPrefix("/static/", |
| 52 | staticHandler(cfg.StaticDir, cfg.ThemeStaticDir()))) |
| 53 | |
| 54 | // Images (served from static/images, so /images/... paths in posts work directly) |
| 55 | mux.Handle("/images/", http.StripPrefix("/images/", |
| 56 | http.FileServer(http.Dir(filepath.Join(cfg.StaticDir, "images"))))) |
| 57 | |
| 58 | // RSS Feed |
| 59 | mux.HandleFunc("/index.xml", func(w http.ResponseWriter, r *http.Request) { |
| 60 | w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") |
| 61 | rssCount := 20 |
| 62 | if rssCount > len(posts) { |
| 63 | rssCount = len(posts) |
| 64 | } |
| 65 | if err := templates.RenderRSS(w, site, posts[:rssCount]); err != nil { |
| 66 | log.Printf("Error rendering RSS: %v", err) |
| 67 | http.Error(w, "Internal Server Error", 500) |
| 68 | } |
| 69 | }) |
| 70 | |
| 71 | // sitemap.xml — URL index for search engines |
| 72 | if cfg.Sitemap.EnabledOrDefault() { |
| 73 | mux.HandleFunc("/sitemap.xml", func(w http.ResponseWriter, r *http.Request) { |
| 74 | w.Header().Set("Content-Type", "application/xml; charset=utf-8") |
| 75 | if err := templates.RenderSitemap(w, site, posts, pages, cfg.PostsPerPage); err != nil { |
| 76 | log.Printf("Error rendering sitemap: %v", err) |
| 77 | http.Error(w, "Internal Server Error", 500) |
| 78 | } |
| 79 | }) |
| 80 | } |
| 81 | |
| 82 | // robots.txt — crawler rules + sitemap reference |
| 83 | if cfg.Robots.EnabledOrDefault() { |
| 84 | mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { |
| 85 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 86 | if err := generator.RenderRobotsTxt(w, cfg); err != nil { |
| 87 | log.Printf("Error rendering robots.txt: %v", err) |
| 88 | http.Error(w, "Internal Server Error", 500) |
| 89 | } |
| 90 | }) |
| 91 | } |
| 92 | |
| 93 | // llms.txt — concise index for LLMs (https://llmstxt.org/) |
| 94 | if cfg.LLMs.EnabledOrDefault() { |
| 95 | mux.HandleFunc("/llms.txt", func(w http.ResponseWriter, r *http.Request) { |
| 96 | w.Header().Set("Content-Type", "text/markdown; charset=utf-8") |
| 97 | if err := generator.RenderLLMsTxt(w, cfg, posts, pages); err != nil { |
| 98 | log.Printf("Error rendering llms.txt: %v", err) |
| 99 | http.Error(w, "Internal Server Error", 500) |
| 100 | } |
| 101 | }) |
| 102 | } |
| 103 | |
| 104 | // llms-full.txt — full markdown of every post |
| 105 | if cfg.LLMs.FullOrDefault() { |
| 106 | mux.HandleFunc("/llms-full.txt", func(w http.ResponseWriter, r *http.Request) { |
| 107 | w.Header().Set("Content-Type", "text/markdown; charset=utf-8") |
| 108 | if err := generator.RenderLLMsFullTxt(w, cfg, posts); err != nil { |
| 109 | log.Printf("Error rendering llms-full.txt: %v", err) |
| 110 | http.Error(w, "Internal Server Error", 500) |
| 111 | } |
| 112 | }) |
| 113 | } |
| 114 | |
| 115 | // Search index |
| 116 | mux.HandleFunc("/search-index.json", func(w http.ResponseWriter, r *http.Request) { |
| 117 | w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| 118 | |
| 119 | months := []string{ |
| 120 | "", "Januar", "Februar", "März", "April", "Mai", "Juni", |
| 121 | "Juli", "August", "September", "Oktober", "November", "Dezember", |
| 122 | } |
| 123 | |
| 124 | type searchEntry struct { |
| 125 | Title string `json:"title"` |
| 126 | Slug string `json:"slug"` |
| 127 | Date string `json:"date"` |
| 128 | Tags []string `json:"tags"` |
| 129 | Categories []string `json:"categories"` |
| 130 | Preview string `json:"preview"` |
| 131 | Cover string `json:"cover"` |
| 132 | URL string `json:"url"` |
| 133 | } |
| 134 | |
| 135 | entries := make([]searchEntry, 0, len(posts)) |
| 136 | for _, p := range posts { |
| 137 | d := p.Date.Time |
| 138 | dateStr := d.Format("02.") + " " + months[d.Month()] + " " + d.Format("2006") |
| 139 | tags := p.Tags |
| 140 | if tags == nil { |
| 141 | tags = []string{} |
| 142 | } |
| 143 | cats := p.Categories |
| 144 | if cats == nil { |
| 145 | cats = []string{} |
| 146 | } |
| 147 | entries = append(entries, searchEntry{ |
| 148 | Title: p.Title, |
| 149 | Slug: p.Slug, |
| 150 | Date: dateStr, |
| 151 | Tags: tags, |
| 152 | Categories: cats, |
| 153 | Preview: p.GetPreview(), |
| 154 | Cover: p.GetCover(), |
| 155 | URL: "/posts/" + p.Slug + "/", |
| 156 | }) |
| 157 | } |
| 158 | |
| 159 | enc := json.NewEncoder(w) |
| 160 | enc.SetIndent("", " ") |
| 161 | if err := enc.Encode(entries); err != nil { |
| 162 | log.Printf("Error encoding search index: %v", err) |
| 163 | http.Error(w, "Internal Server Error", 500) |
| 164 | } |
| 165 | }) |
| 166 | |
| 167 | // Pagination helper |
| 168 | perPage := cfg.PostsPerPage |
| 169 | totalPages := (len(posts) + perPage - 1) / perPage |
| 170 | if totalPages < 1 { |
| 171 | totalPages = 1 |
| 172 | } |
| 173 | |
| 174 | renderHomePage := func(w http.ResponseWriter, r *http.Request, page int) { |
| 175 | if page < 1 || page > totalPages { |
| 176 | http.NotFound(w, r) |
| 177 | return |
| 178 | } |
| 179 | start := (page - 1) * perPage |
| 180 | end := start + perPage |
| 181 | if end > len(posts) { |
| 182 | end = len(posts) |
| 183 | } |
| 184 | data := templates.HomeData{ |
| 185 | Site: site, |
| 186 | Posts: posts[start:end], |
| 187 | Page: page, |
| 188 | TotalPages: totalPages, |
| 189 | } |
| 190 | if err := templates.RenderHome(w, data); err != nil { |
| 191 | log.Printf("Error rendering home: %v", err) |
| 192 | http.Error(w, "Internal Server Error", 500) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | // Homepage (page 1) |
| 197 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { |
| 198 | if r.URL.Path != "/" { |
| 199 | http.NotFound(w, r) |
| 200 | return |
| 201 | } |
| 202 | renderHomePage(w, r, 1) |
| 203 | }) |
| 204 | |
| 205 | // Paginated pages: /page/2, /page/3, ... |
| 206 | mux.HandleFunc("/page/", func(w http.ResponseWriter, r *http.Request) { |
| 207 | numStr := strings.TrimPrefix(r.URL.Path, "/page/") |
| 208 | numStr = strings.TrimSuffix(numStr, "/") |
| 209 | page, err := strconv.Atoi(numStr) |
| 210 | if err != nil || page < 1 { |
| 211 | http.NotFound(w, r) |
| 212 | return |
| 213 | } |
| 214 | if page == 1 { |
| 215 | http.Redirect(w, r, "/", http.StatusMovedPermanently) |
| 216 | return |
| 217 | } |
| 218 | renderHomePage(w, r, page) |
| 219 | }) |
| 220 | |
| 221 | // Individual posts |
| 222 | mux.HandleFunc("/posts/", func(w http.ResponseWriter, r *http.Request) { |
| 223 | slug := strings.TrimPrefix(r.URL.Path, "/posts/") |
| 224 | slug = strings.TrimSuffix(slug, "/") |
| 225 | post, ok := postMap[slug] |
| 226 | if !ok { |
| 227 | http.NotFound(w, r) |
| 228 | return |
| 229 | } |
| 230 | data := templates.PostData{Site: site, Post: post} |
| 231 | // Posts are sorted newest first: index 0 = newest |
| 232 | idx := postIndex[slug] |
| 233 | if idx > 0 { |
| 234 | data.PrevPost = posts[idx-1] // newer |
| 235 | } |
| 236 | if idx < len(posts)-1 { |
| 237 | data.NextPost = posts[idx+1] // older |
| 238 | } |
| 239 | if err := templates.RenderPost(w, data); err != nil { |
| 240 | log.Printf("Error rendering post: %v", err) |
| 241 | http.Error(w, "Internal Server Error", 500) |
| 242 | } |
| 243 | }) |
| 244 | |
| 245 | // Static pages (sites) |
| 246 | mux.HandleFunc("/sites/", func(w http.ResponseWriter, r *http.Request) { |
| 247 | slug := strings.TrimPrefix(r.URL.Path, "/sites/") |
| 248 | slug = strings.TrimSuffix(slug, "/") |
| 249 | page, ok := pageMap[slug] |
| 250 | if !ok { |
| 251 | http.NotFound(w, r) |
| 252 | return |
| 253 | } |
| 254 | data := templates.PageData{Site: site, Page: page} |
| 255 | if err := templates.RenderPage(w, data); err != nil { |
| 256 | log.Printf("Error rendering page: %v", err) |
| 257 | http.Error(w, "Internal Server Error", 500) |
| 258 | } |
| 259 | }) |
| 260 | |
| 261 | // Tags index + single tag |
| 262 | renderTagsIndex := func(w http.ResponseWriter) { |
| 263 | tagCounts := make(map[string]int) |
| 264 | for tag, tagPosts := range tagMap { |
| 265 | tagCounts[tag] = len(tagPosts) |
| 266 | } |
| 267 | data := templates.TagsData{Site: site, Tags: tagCounts} |
| 268 | if err := templates.RenderTags(w, data); err != nil { |
| 269 | log.Printf("Error rendering tags: %v", err) |
| 270 | http.Error(w, "Internal Server Error", 500) |
| 271 | } |
| 272 | } |
| 273 | mux.HandleFunc("/tags", func(w http.ResponseWriter, r *http.Request) { |
| 274 | renderTagsIndex(w) |
| 275 | }) |
| 276 | mux.HandleFunc("/tags/", func(w http.ResponseWriter, r *http.Request) { |
| 277 | tag := strings.TrimPrefix(r.URL.Path, "/tags/") |
| 278 | tag = strings.TrimSuffix(tag, "/") |
| 279 | if tag == "" { |
| 280 | renderTagsIndex(w) |
| 281 | return |
| 282 | } |
| 283 | handleTag(w, r, site, tagMap) |
| 284 | }) |
| 285 | |
| 286 | // Categories index + single category |
| 287 | renderCategoriesIndex := func(w http.ResponseWriter) { |
| 288 | catCounts := make(map[string]int) |
| 289 | for cat, catPosts := range catMap { |
| 290 | catCounts[cat] = len(catPosts) |
| 291 | } |
| 292 | data := templates.CategoriesData{Site: site, Categories: catCounts} |
| 293 | if err := templates.RenderCategories(w, data); err != nil { |
| 294 | log.Printf("Error rendering categories: %v", err) |
| 295 | http.Error(w, "Internal Server Error", 500) |
| 296 | } |
| 297 | } |
| 298 | mux.HandleFunc("/categories", func(w http.ResponseWriter, r *http.Request) { |
| 299 | renderCategoriesIndex(w) |
| 300 | }) |
| 301 | mux.HandleFunc("/categories/", func(w http.ResponseWriter, r *http.Request) { |
| 302 | cat := strings.TrimPrefix(r.URL.Path, "/categories/") |
| 303 | cat = strings.TrimSuffix(cat, "/") |
| 304 | if cat == "" { |
| 305 | renderCategoriesIndex(w) |
| 306 | return |
| 307 | } |
| 308 | handleCategory(w, r, site, catMap) |
| 309 | }) |
| 310 | |
| 311 | var handler http.Handler = mux |
| 312 | if templates.DevMode() { |
| 313 | // Templates are re-parsed per request; keep the browser from caching |
| 314 | // the theme's CSS/JS so edits show up on reload. |
| 315 | handler = noCache(mux) |
| 316 | log.Printf("Dev mode: templates in %s are reloaded on every request", cfg.ThemeTemplatesDir()) |
| 317 | } |
| 318 | |
| 319 | addr := fmt.Sprintf(":%d", cfg.Port) |
| 320 | log.Printf("Blog server starting on http://localhost%s", addr) |
| 321 | log.Fatal(http.ListenAndServe(addr, handler)) |
| 322 | } |
| 323 | |
| 324 | // noCache disables browser caching — used in dev mode only. |
| 325 | func noCache(next http.Handler) http.Handler { |
| 326 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 327 | w.Header().Set("Cache-Control", "no-store, max-age=0") |
| 328 | next.ServeHTTP(w, r) |
| 329 | }) |
| 330 | } |
| 331 | |
| 332 | // staticHandler serves static assets from several directories: the first |
| 333 | // directory that contains the requested file wins. |
| 334 | func staticHandler(dirs ...string) http.Handler { |
| 335 | servers := make([]http.Handler, len(dirs)) |
| 336 | for i, d := range dirs { |
| 337 | servers[i] = http.FileServer(http.Dir(d)) |
| 338 | } |
| 339 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 340 | rel := filepath.FromSlash(path.Clean("/" + r.URL.Path)) |
| 341 | for i, d := range dirs { |
| 342 | if info, err := os.Stat(filepath.Join(d, rel)); err == nil && !info.IsDir() { |
| 343 | servers[i].ServeHTTP(w, r) |
| 344 | return |
| 345 | } |
| 346 | } |
| 347 | http.NotFound(w, r) |
| 348 | }) |
| 349 | } |
| 350 | |
| 351 | func handleTag(w http.ResponseWriter, r *http.Request, site templates.SiteData, tagMap map[string][]*content.Post) { |
| 352 | tag := strings.TrimPrefix(r.URL.Path, "/tags/") |
| 353 | tag = strings.TrimSuffix(tag, "/") |
| 354 | if tag == "" { |
| 355 | http.Redirect(w, r, "/tags", http.StatusFound) |
| 356 | return |
| 357 | } |
| 358 | for t, tagPosts := range tagMap { |
| 359 | if strings.EqualFold(t, tag) { |
| 360 | data := templates.TagData{Site: site, Tag: t, Posts: tagPosts} |
| 361 | if err := templates.RenderTag(w, data); err != nil { |
| 362 | log.Printf("Error rendering tag: %v", err) |
| 363 | http.Error(w, "Internal Server Error", 500) |
| 364 | } |
| 365 | return |
| 366 | } |
| 367 | } |
| 368 | http.NotFound(w, r) |
| 369 | } |
| 370 | |
| 371 | func handleCategory(w http.ResponseWriter, r *http.Request, site templates.SiteData, catMap map[string][]*content.Post) { |
| 372 | cat := strings.TrimPrefix(r.URL.Path, "/categories/") |
| 373 | cat = strings.TrimSuffix(cat, "/") |
| 374 | if cat == "" { |
| 375 | http.Redirect(w, r, "/categories", http.StatusFound) |
| 376 | return |
| 377 | } |
| 378 | for c, catPosts := range catMap { |
| 379 | if strings.EqualFold(c, cat) { |
| 380 | data := templates.CategoryData{Site: site, Category: c, Posts: catPosts} |
| 381 | if err := templates.RenderCategory(w, data); err != nil { |
| 382 | log.Printf("Error rendering category: %v", err) |
| 383 | http.Error(w, "Internal Server Error", 500) |
| 384 | } |
| 385 | return |
| 386 | } |
| 387 | } |
| 388 | http.NotFound(w, r) |
| 389 | } |