internal/generator/generator.go
229 Zeilen · 6.7 KB · Go
| 1 | package generator |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "io/fs" |
| 6 | "log" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/ruedigerp/newblog/internal/config" |
| 12 | "github.com/ruedigerp/newblog/internal/content" |
| 13 | "github.com/ruedigerp/newblog/internal/templates" |
| 14 | ) |
| 15 | |
| 16 | func Generate(cfg *config.Config, posts []*content.Post, pages []*content.Page) { |
| 17 | if err := templates.Init(cfg.ThemeDir()); err != nil { |
| 18 | log.Fatalf("Failed to init templates: %v", err) |
| 19 | } |
| 20 | log.Printf("Using theme %q from %s", cfg.Theme, cfg.ThemeDir()) |
| 21 | |
| 22 | out := cfg.OutputDir |
| 23 | |
| 24 | // Clean and recreate output directory |
| 25 | os.RemoveAll(out) |
| 26 | os.MkdirAll(out, 0755) |
| 27 | |
| 28 | site := templates.SiteData{ |
| 29 | Title: cfg.Title, |
| 30 | Description: cfg.Description, |
| 31 | Author: cfg.Author, |
| 32 | BaseURL: cfg.BaseURL, |
| 33 | Menu: cfg.Menu, |
| 34 | } |
| 35 | |
| 36 | // 1. Homepage with pagination |
| 37 | perPage := cfg.PostsPerPage |
| 38 | totalPages := (len(posts) + perPage - 1) / perPage |
| 39 | if totalPages < 1 { |
| 40 | totalPages = 1 |
| 41 | } |
| 42 | for page := 1; page <= totalPages; page++ { |
| 43 | start := (page - 1) * perPage |
| 44 | end := start + perPage |
| 45 | if end > len(posts) { |
| 46 | end = len(posts) |
| 47 | } |
| 48 | data := templates.HomeData{ |
| 49 | Site: site, |
| 50 | Posts: posts[start:end], |
| 51 | Page: page, |
| 52 | TotalPages: totalPages, |
| 53 | } |
| 54 | if page == 1 { |
| 55 | writeTemplate(filepath.Join(out, "index.html"), func(f *os.File) error { |
| 56 | return templates.RenderHome(f, data) |
| 57 | }) |
| 58 | } |
| 59 | // Also generate /page/N/ for all pages (including page 1 as redirect target) |
| 60 | if page > 1 { |
| 61 | dir := filepath.Join(out, "page", fmt.Sprintf("%d", page)) |
| 62 | os.MkdirAll(dir, 0755) |
| 63 | pageCopy := data // capture for closure |
| 64 | writeTemplate(filepath.Join(dir, "index.html"), func(f *os.File) error { |
| 65 | return templates.RenderHome(f, pageCopy) |
| 66 | }) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // 2. Individual posts |
| 71 | for i, post := range posts { |
| 72 | dir := filepath.Join(out, "posts", post.Slug) |
| 73 | os.MkdirAll(dir, 0755) |
| 74 | data := templates.PostData{Site: site, Post: post} |
| 75 | // Posts are sorted newest first: index 0 = newest |
| 76 | if i > 0 { |
| 77 | data.PrevPost = posts[i-1] // newer |
| 78 | } |
| 79 | if i < len(posts)-1 { |
| 80 | data.NextPost = posts[i+1] // older |
| 81 | } |
| 82 | writeTemplate(filepath.Join(dir, "index.html"), func(f *os.File) error { |
| 83 | return templates.RenderPost(f, data) |
| 84 | }) |
| 85 | } |
| 86 | |
| 87 | // 3. Tags index |
| 88 | tagMap := content.CollectTags(posts) |
| 89 | tagCounts := make(map[string]int) |
| 90 | for tag, tagPosts := range tagMap { |
| 91 | tagCounts[tag] = len(tagPosts) |
| 92 | } |
| 93 | os.MkdirAll(filepath.Join(out, "tags"), 0755) |
| 94 | writeTemplate(filepath.Join(out, "tags", "index.html"), func(f *os.File) error { |
| 95 | return templates.RenderTags(f, templates.TagsData{Site: site, Tags: tagCounts}) |
| 96 | }) |
| 97 | |
| 98 | // 4. Per-tag pages |
| 99 | for tag, tagPosts := range tagMap { |
| 100 | dir := filepath.Join(out, "tags", strings.ToLower(tag)) |
| 101 | os.MkdirAll(dir, 0755) |
| 102 | writeTemplate(filepath.Join(dir, "index.html"), func(f *os.File) error { |
| 103 | return templates.RenderTag(f, templates.TagData{Site: site, Tag: tag, Posts: tagPosts}) |
| 104 | }) |
| 105 | } |
| 106 | |
| 107 | // 5. Categories index |
| 108 | catMap := content.CollectCategories(posts) |
| 109 | catCounts := make(map[string]int) |
| 110 | for cat, catPosts := range catMap { |
| 111 | catCounts[cat] = len(catPosts) |
| 112 | } |
| 113 | os.MkdirAll(filepath.Join(out, "categories"), 0755) |
| 114 | writeTemplate(filepath.Join(out, "categories", "index.html"), func(f *os.File) error { |
| 115 | return templates.RenderCategories(f, templates.CategoriesData{Site: site, Categories: catCounts}) |
| 116 | }) |
| 117 | |
| 118 | // 6. Per-category pages |
| 119 | for cat, catPosts := range catMap { |
| 120 | dir := filepath.Join(out, "categories", strings.ToLower(cat)) |
| 121 | os.MkdirAll(dir, 0755) |
| 122 | writeTemplate(filepath.Join(dir, "index.html"), func(f *os.File) error { |
| 123 | return templates.RenderCategory(f, templates.CategoryData{Site: site, Category: cat, Posts: catPosts}) |
| 124 | }) |
| 125 | } |
| 126 | |
| 127 | // 7. Static pages (sites) |
| 128 | for _, page := range pages { |
| 129 | dir := filepath.Join(out, "sites", page.Slug) |
| 130 | os.MkdirAll(dir, 0755) |
| 131 | writeTemplate(filepath.Join(dir, "index.html"), func(f *os.File) error { |
| 132 | return templates.RenderPage(f, templates.PageData{Site: site, Page: page}) |
| 133 | }) |
| 134 | } |
| 135 | |
| 136 | // 8. RSS Feed |
| 137 | rssCount := 20 |
| 138 | if rssCount > len(posts) { |
| 139 | rssCount = len(posts) |
| 140 | } |
| 141 | writeTemplate(filepath.Join(out, "index.xml"), func(f *os.File) error { |
| 142 | return templates.RenderRSS(f, site, posts[:rssCount]) |
| 143 | }) |
| 144 | |
| 145 | // 8b. sitemap.xml for search engines |
| 146 | if cfg.Sitemap.EnabledOrDefault() { |
| 147 | writeTemplate(filepath.Join(out, "sitemap.xml"), func(f *os.File) error { |
| 148 | return templates.RenderSitemap(f, site, posts, pages, cfg.PostsPerPage) |
| 149 | }) |
| 150 | } |
| 151 | |
| 152 | // 8c. robots.txt (references the sitemap when enabled) |
| 153 | GenerateRobotsTxt(cfg, out) |
| 154 | |
| 155 | // 9. Search index |
| 156 | GenerateSearchIndex(posts, filepath.Join(out, "search-index.json")) |
| 157 | |
| 158 | // 9b. llms.txt / llms-full.txt for LLM ingestion (https://llmstxt.org/) |
| 159 | GenerateLLMsFiles(cfg, posts, pages, out) |
| 160 | |
| 161 | // 10. Copy static assets: theme assets first, site assets afterwards so |
| 162 | // files in the site's static/ override the ones shipped with the theme. |
| 163 | // (skip images/ subdirectory — handled separately) |
| 164 | // Theme assets are copied as-is: a theme's own images belong to /static/images/. |
| 165 | if _, err := os.Stat(cfg.ThemeStaticDir()); err == nil { |
| 166 | copyStaticDir(cfg.ThemeStaticDir(), filepath.Join(out, "static"), nil) |
| 167 | } |
| 168 | copyStaticDir(cfg.StaticDir, filepath.Join(out, "static"), []string{"images", "videos"}) |
| 169 | |
| 170 | // 11. Copy images + videos to root /images/ and /videos/ (cover paths use /images/...) |
| 171 | for _, dir := range []string{"images", "videos"} { |
| 172 | src := filepath.Join(cfg.StaticDir, dir) |
| 173 | dst := filepath.Join(out, dir) |
| 174 | if _, err := os.Stat(src); err == nil { |
| 175 | copyStaticDir(src, dst, nil) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | extras := "RSS feed" |
| 180 | if cfg.Sitemap.EnabledOrDefault() { |
| 181 | extras += ", sitemap" |
| 182 | } |
| 183 | if cfg.Robots.EnabledOrDefault() { |
| 184 | extras += ", robots.txt" |
| 185 | } |
| 186 | log.Printf("Generated %d posts, %d pages, %d tag pages, %d category pages, %s into %s/", |
| 187 | len(posts), len(pages), len(tagMap), len(catMap), extras, out) |
| 188 | } |
| 189 | |
| 190 | func writeTemplate(path string, render func(f *os.File) error) { |
| 191 | f, err := os.Create(path) |
| 192 | if err != nil { |
| 193 | log.Printf("Error creating %s: %v", path, err) |
| 194 | return |
| 195 | } |
| 196 | defer f.Close() |
| 197 | if err := render(f); err != nil { |
| 198 | log.Printf("Error rendering %s: %v", path, err) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | func copyStaticDir(src, dst string, skipDirs []string) { |
| 203 | skipSet := make(map[string]bool) |
| 204 | for _, d := range skipDirs { |
| 205 | skipSet[d] = true |
| 206 | } |
| 207 | filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { |
| 208 | if err != nil { |
| 209 | return err |
| 210 | } |
| 211 | rel, _ := filepath.Rel(src, path) |
| 212 | // Skip top-level directories in the skip list |
| 213 | if d.IsDir() && rel != "." && skipSet[strings.Split(rel, string(filepath.Separator))[0]] { |
| 214 | return filepath.SkipDir |
| 215 | } |
| 216 | target := filepath.Join(dst, rel) |
| 217 | if d.IsDir() { |
| 218 | os.MkdirAll(target, 0755) |
| 219 | return nil |
| 220 | } |
| 221 | data, err := os.ReadFile(path) |
| 222 | if err != nil { |
| 223 | log.Printf("Error reading %s: %v", path, err) |
| 224 | return nil |
| 225 | } |
| 226 | os.MkdirAll(filepath.Dir(target), 0755) |
| 227 | return os.WriteFile(target, data, 0644) |
| 228 | }) |
| 229 | } |