fix: add sitemap and robots.txt
R
Rüdiger Küpper
<rpr@9it.de>
committete am 18.08.2026 04:17
89f2d506160faa065851bc8d95d24aca64dbd65b
| @@ -181,6 +181,47 @@ Automatisch generierter RSS 2.0 Feed unter `/index.xml` mit: | ||
| 181 | 181 | - RSS-Icon in der Navigation |
| 182 | 182 | - URL `/index.xml` für Kompatibilität mit bestehenden Blog-Setups |
| 183 | 183 | |
| 184 | +### Sitemap |
|
| 185 | + | |
| 186 | +Parallel zum RSS-Feed wird eine `sitemap.xml` nach dem [sitemaps.org-Protokoll 0.9](https://www.sitemaps.org/protocol.html) erzeugt - unter `/sitemap.xml` sowohl im dynamischen Server als auch in der statischen Generierung. Enthalten sind: |
|
| 187 | + | |
| 188 | +- Startseite und alle Pagination-Seiten (`/page/2/`, ...) |
|
| 189 | +- Alle Blog-Posts (`lastmod` aus `update`, sonst `date`) |
|
| 190 | +- Alle statischen Seiten aus `sites/` |
|
| 191 | +- Tag- und Kategorie-Übersichten sowie alle einzelnen Tag-/Kategorie-Seiten |
|
| 192 | + | |
| 193 | +Die Sitemap lässt sich in der Config an- und abschalten (Default: aktiv): |
|
| 194 | + | |
| 195 | +```yaml |
|
| 196 | +sitemap: |
|
| 197 | +enabled: true # false schaltet /sitemap.xml komplett ab |
|
| 198 | +``` |
|
| 199 | + | |
| 200 | +### robots.txt |
|
| 201 | + | |
| 202 | +Ebenfalls automatisch erzeugt: eine `robots.txt` unter `/robots.txt`. Sie verweist automatisch auf die Sitemap, sobald diese aktiviert ist, und lässt sich in der Config konfigurieren (Default: aktiv): |
|
| 203 | + | |
| 204 | +```yaml |
|
| 205 | +robots: |
|
| 206 | +enabled: true |
|
| 207 | +userAgent: "*" # optional, Default "*" |
|
| 208 | +disallow: # optional |
|
| 209 | +- /search-index.json |
|
| 210 | +allow: # optional |
|
| 211 | +- / |
|
| 212 | +``` |
|
| 213 | + | |
| 214 | +Ohne eigene `allow`/`disallow`-Regeln wird `Allow: /` geschrieben, d.h. die ganze Seite ist freigegeben: |
|
| 215 | + | |
| 216 | +``` |
|
| 217 | +User-agent: * |
|
| 218 | +Allow: / |
|
| 219 | + | |
| 220 | +Sitemap: https://blog.kuepper.nrw/sitemap.xml |
|
| 221 | +``` |
|
| 222 | + | |
| 223 | +Ist `sitemap.enabled: false` oder `baseURL` leer, entfällt die `Sitemap:`-Zeile. |
|
| 224 | + | |
| 184 | 225 | ### Cover-Bilder |
| 185 | 226 | |
| 186 | 227 | Posts können ein Cover-Bild definieren, das sowohl auf der Startseite (als Post-Card) als auch auf der Einzelansicht angezeigt wird: |
| @@ -235,10 +276,12 @@ quilldrop/ | ||
| 235 | 276 | │ ├── server/server.go # HTTP Server |
| 236 | 277 | │ ├── generator/ |
| 237 | 278 | │ │ ├── generator.go # Static Site Generator |
| 279 | +│ │ ├── robots.go # robots.txt Generator |
|
| 238 | 280 | │ │ └── search.go # Search-Index Generator (JSON) |
| 239 | 281 | │ └── templates/ |
| 240 | 282 | │ ├── render.go # Template Engine + Functions (lädt das Theme) |
| 241 | -│ └── rss.go # RSS Feed Generator |
|
| 283 | +│ ├── rss.go # RSS Feed Generator |
|
| 284 | +│ └── sitemap.go # sitemap.xml Generator |
|
| 242 | 285 | └── output/ # Generierte statische Dateien |
| 243 | 286 | ``` |
| 244 | 287 | |
| @@ -356,7 +399,7 @@ menu: | ||
| 356 | 399 | | `title` | - | Titel der Website | |
| 357 | 400 | | `description` | - | Beschreibung (Meta-Tag + Hero) | |
| 358 | 401 | | `author` | - | Autor der Website | |
| 359 | -| `baseURL` | - | Basis-URL für RSS und absolute Links | |
|
| 402 | +| `baseURL` | - | Basis-URL für RSS, Sitemap und absolute Links | |
|
| 360 | 403 | | `port` | `8080` | Port für den dynamischen Server | |
| 361 | 404 | | `postsPerPage` | `5` | Anzahl Posts pro Seite | |
| 362 | 405 | | `contentDir` | `content` | Verzeichnis für Blog-Posts | |
| @@ -366,6 +409,11 @@ menu: | ||
| 366 | 409 | | `themesDir` | `themes` | Verzeichnis mit allen Themes | |
| 367 | 410 | | `theme` | `default` | Aktives Theme, gelesen aus `<themesDir>/<theme>/` (Override: `-theme`) | |
| 368 | 411 | | `menu` | `[]` | Navigationsmenü mit optionalen Untermenüs | |
| 412 | +| `sitemap.enabled` | `true` | Erzeugt/serviert `/sitemap.xml` | |
|
| 413 | +| `robots.enabled` | `true` | Erzeugt/serviert `/robots.txt` (mit Sitemap-Verweis) | |
|
| 414 | +| `robots.userAgent` | `*` | User-agent-Zeile in der `robots.txt` | |
|
| 415 | +| `robots.allow` | `[]` | Zusätzliche `Allow:`-Regeln | |
|
| 416 | +| `robots.disallow` | `[]` | Zusätzliche `Disallow:`-Regeln | |
|
| 369 | 417 | |
| 370 | 418 | ## Schnellstart |
| 371 | 419 | |
| @@ -450,6 +498,8 @@ Alle URLs verwenden konsequent Trailing Slashes, um serverseitige Redirects zu v | ||
| 450 | 498 | | `/sites/ueber-mich/` | Statische Seite | |
| 451 | 499 | | `/sites/projekte/vm-tracker/` | Verschachtelte Projektseite | |
| 452 | 500 | | `/index.xml` | RSS Feed | |
| 501 | +| `/sitemap.xml` | Sitemap für Suchmaschinen (falls aktiviert) | |
|
| 502 | +| `/robots.txt` | Crawler-Regeln + Sitemap-Verweis (falls aktiviert) | |
|
| 453 | 503 | | `/search-index.json` | Suchindex (JSON) | |
| 454 | 504 | | `/static/css/style.css` | Statische Assets | |
| 455 | 505 | | `/images/posts/2025/11/cover.webp` | Bilder | |
| @@ -183,6 +183,47 @@ Automatically generated RSS 2.0 feed at `/index.xml` with: | ||
| 183 | 183 | - RSS icon in the navigation |
| 184 | 184 | - URL `/index.xml` for compatibility with existing blog setups |
| 185 | 185 | |
| 186 | +### Sitemap |
|
| 187 | + | |
| 188 | +Alongside the RSS feed, a `sitemap.xml` following the [sitemaps.org protocol 0.9](https://www.sitemaps.org/protocol.html) is produced — served at `/sitemap.xml` by the dynamic server and written by the static generator. It contains: |
|
| 189 | + | |
| 190 | +- Homepage and every pagination page (`/page/2/`, ...) |
|
| 191 | +- All blog posts (`lastmod` from `update`, falling back to `date`) |
|
| 192 | +- All static pages from `sites/` |
|
| 193 | +- Tag and category overviews plus every single tag/category page |
|
| 194 | + | |
| 195 | +The sitemap can be switched on and off in the config (default: on): |
|
| 196 | + | |
| 197 | +```yaml |
|
| 198 | +sitemap: |
|
| 199 | +enabled: true # false disables /sitemap.xml entirely |
|
| 200 | +``` |
|
| 201 | + | |
| 202 | +### robots.txt |
|
| 203 | + | |
| 204 | +A `robots.txt` is generated as well and served at `/robots.txt`. It references the sitemap automatically whenever sitemap generation is enabled, and is configurable (default: on): |
|
| 205 | + | |
| 206 | +```yaml |
|
| 207 | +robots: |
|
| 208 | +enabled: true |
|
| 209 | +userAgent: "*" # optional, defaults to "*" |
|
| 210 | +disallow: # optional |
|
| 211 | +- /search-index.json |
|
| 212 | +allow: # optional |
|
| 213 | +- / |
|
| 214 | +``` |
|
| 215 | + | |
| 216 | +Without custom `allow`/`disallow` rules an `Allow: /` is written, i.e. the whole site is crawlable: |
|
| 217 | + | |
| 218 | +``` |
|
| 219 | +User-agent: * |
|
| 220 | +Allow: / |
|
| 221 | + | |
| 222 | +Sitemap: https://blog.kuepper.nrw/sitemap.xml |
|
| 223 | +``` |
|
| 224 | + | |
| 225 | +If `sitemap.enabled: false` or `baseURL` is empty, the `Sitemap:` line is omitted. |
|
| 226 | + | |
| 186 | 227 | ### Cover Images |
| 187 | 228 | |
| 188 | 229 | Posts can define a cover image that is displayed both on the homepage (as a post card) and on the detail view: |
| @@ -237,10 +278,12 @@ quilldrop/ | ||
| 237 | 278 | │ ├── server/server.go # HTTP server |
| 238 | 279 | │ ├── generator/ |
| 239 | 280 | │ │ ├── generator.go # Static site generator |
| 281 | +│ │ ├── robots.go # robots.txt generator |
|
| 240 | 282 | │ │ └── search.go # Search index generator (JSON) |
| 241 | 283 | │ └── templates/ |
| 242 | 284 | │ ├── render.go # Template engine + functions (loads the theme) |
| 243 | -│ └── rss.go # RSS feed generator |
|
| 285 | +│ ├── rss.go # RSS feed generator |
|
| 286 | +│ └── sitemap.go # sitemap.xml generator |
|
| 244 | 287 | └── output/ # Generated static files |
| 245 | 288 | ``` |
| 246 | 289 | |
| @@ -356,7 +399,7 @@ menu: | ||
| 356 | 399 | | `title` | — | Website title | |
| 357 | 400 | | `description` | — | Description (meta tag + hero) | |
| 358 | 401 | | `author` | — | Website author | |
| 359 | -| `baseURL` | — | Base URL for RSS and absolute links | |
|
| 402 | +| `baseURL` | — | Base URL for RSS, sitemap and absolute links | |
|
| 360 | 403 | | `port` | `8080` | Port for the dynamic server | |
| 361 | 404 | | `postsPerPage` | `5` | Number of posts per page | |
| 362 | 405 | | `contentDir` | `content` | Directory for blog posts | |
| @@ -366,6 +409,11 @@ menu: | ||
| 366 | 409 | | `themesDir` | `themes` | Directory containing all themes | |
| 367 | 410 | | `theme` | `default` | Active theme, read from `<themesDir>/<theme>/` (override: `-theme`) | |
| 368 | 411 | | `menu` | `[]` | Navigation menu with optional submenus | |
| 412 | +| `sitemap.enabled` | `true` | Generates/serves `/sitemap.xml` | |
|
| 413 | +| `robots.enabled` | `true` | Generates/serves `/robots.txt` (with sitemap reference) | |
|
| 414 | +| `robots.userAgent` | `*` | User-agent line in `robots.txt` | |
|
| 415 | +| `robots.allow` | `[]` | Additional `Allow:` rules | |
|
| 416 | +| `robots.disallow` | `[]` | Additional `Disallow:` rules | |
|
| 369 | 417 | |
| 370 | 418 | ## Quick Start |
| 371 | 419 | |
| @@ -450,6 +498,8 @@ All URLs consistently use trailing slashes to avoid server-side redirects: | ||
| 450 | 498 | | `/sites/about/` | Static page | |
| 451 | 499 | | `/sites/projects/vm-tracker/` | Nested project page | |
| 452 | 500 | | `/index.xml` | RSS feed | |
| 501 | +| `/sitemap.xml` | Sitemap for search engines (if enabled) | |
|
| 502 | +| `/robots.txt` | Crawler rules + sitemap reference (if enabled) | |
|
| 453 | 503 | | `/search-index.json` | Search index (JSON) | |
| 454 | 504 | | `/static/css/style.css` | Static assets | |
| 455 | 505 | | `/images/posts/2025/11/cover.webp` | Images | |
| @@ -13,6 +13,19 @@ outputDir: "output" | ||
| 13 | 13 | themesDir: "themes" |
| 14 | 14 | theme: "ehc" |
| 15 | 15 | |
| 16 | +# sitemap.xml für Suchmaschinen (Standard: true) |
|
| 17 | +sitemap: |
|
| 18 | +enabled: true |
|
| 19 | + | |
| 20 | +# robots.txt (Standard: true), verweist automatisch auf die Sitemap |
|
| 21 | +robots: |
|
| 22 | +enabled: true |
|
| 23 | +# userAgent: "*" |
|
| 24 | +# allow: |
|
| 25 | +# - / |
|
| 26 | +# disallow: |
|
| 27 | +# - /search-index.json |
|
| 28 | + | |
| 16 | 29 | menu: |
| 17 | 30 | - label: "Home" |
| 18 | 31 | url: "/" |
| @@ -36,21 +36,63 @@ func (l LLMsConfig) FullOrDefault() bool { | ||
| 36 | 36 | return *l.Full |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | +// SitemapConfig toggles generation/serving of sitemap.xml for search engines. |
|
| 40 | +// Defaults to true so behaviour without the block stays unchanged. |
|
| 41 | +type SitemapConfig struct { |
|
| 42 | +Enabled *bool `yaml:"enabled,omitempty"` // sitemap.xml |
|
| 43 | +} |
|
| 44 | + | |
| 45 | +// EnabledOrDefault returns whether sitemap.xml should be produced (default: true). |
|
| 46 | +func (s SitemapConfig) EnabledOrDefault() bool { |
|
| 47 | + if s.Enabled == nil { |
|
| 48 | +return true |
|
| 49 | +} |
|
| 50 | +return *s.Enabled |
|
| 51 | +} |
|
| 52 | + | |
| 53 | +// RobotsConfig toggles generation/serving of robots.txt and lets the site |
|
| 54 | +// define its own Allow/Disallow rules. Defaults to true so behaviour without |
|
| 55 | +// the block stays unchanged. |
|
| 56 | +type RobotsConfig struct { |
|
| 57 | +Enabled *bool `yaml:"enabled,omitempty"` // robots.txt |
|
| 58 | +UserAgent string `yaml:"userAgent,omitempty"` // default: "*" |
|
| 59 | +Allow []string `yaml:"allow,omitempty"` |
|
| 60 | +Disallow []string `yaml:"disallow,omitempty"` |
|
| 61 | +} |
|
| 62 | + | |
| 63 | +// EnabledOrDefault returns whether robots.txt should be produced (default: true). |
|
| 64 | +func (r RobotsConfig) EnabledOrDefault() bool { |
|
| 65 | + if r.Enabled == nil { |
|
| 66 | +return true |
|
| 67 | +} |
|
| 68 | +return *r.Enabled |
|
| 69 | +} |
|
| 70 | + | |
| 71 | +// UserAgentOrDefault returns the User-agent line's value (default: "*"). |
|
| 72 | +func (r RobotsConfig) UserAgentOrDefault() string { |
|
| 73 | + if r.UserAgent == "" { |
|
| 74 | +return "*" |
|
| 75 | +} |
|
| 76 | +return r.UserAgent |
|
| 77 | +} |
|
| 78 | + | |
| 39 | 79 | type Config struct { |
| 40 | -Title string `yaml:"title"` |
|
| 41 | -Description string `yaml:"description"` |
|
| 42 | -Author string `yaml:"author"` |
|
| 43 | -BaseURL string `yaml:"baseURL"` |
|
| 44 | -Port int `yaml:"port"` |
|
| 45 | -PostsPerPage int `yaml:"postsPerPage"` |
|
| 46 | -ContentDir string `yaml:"contentDir"` |
|
| 47 | -SitesDir string `yaml:"sitesDir"` |
|
| 48 | -StaticDir string `yaml:"staticDir"` |
|
| 49 | -OutputDir string `yaml:"outputDir"` |
|
| 50 | -ThemesDir string `yaml:"themesDir"` |
|
| 51 | -Theme string `yaml:"theme"` |
|
| 52 | -Menu []MenuItem `yaml:"menu"` |
|
| 53 | -LLMs LLMsConfig `yaml:"llms"` |
|
| 80 | +Title string `yaml:"title"` |
|
| 81 | +Description string `yaml:"description"` |
|
| 82 | +Author string `yaml:"author"` |
|
| 83 | +BaseURL string `yaml:"baseURL"` |
|
| 84 | +Port int `yaml:"port"` |
|
| 85 | +PostsPerPage int `yaml:"postsPerPage"` |
|
| 86 | +ContentDir string `yaml:"contentDir"` |
|
| 87 | +SitesDir string `yaml:"sitesDir"` |
|
| 88 | +StaticDir string `yaml:"staticDir"` |
|
| 89 | +OutputDir string `yaml:"outputDir"` |
|
| 90 | +ThemesDir string `yaml:"themesDir"` |
|
| 91 | +Theme string `yaml:"theme"` |
|
| 92 | +Menu []MenuItem `yaml:"menu"` |
|
| 93 | +LLMs LLMsConfig `yaml:"llms"` |
|
| 94 | +Sitemap SitemapConfig `yaml:"sitemap"` |
|
| 95 | +Robots RobotsConfig `yaml:"robots"` |
|
| 54 | 96 | } |
| 55 | 97 | |
| 56 | 98 | // ThemeDir returns the directory of the active theme, e.g. "themes/default". |
| @@ -142,6 +142,16 @@ func Generate(cfg *config.Config, posts []*content.Post, pages []*content.Page) | ||
| 142 | 142 | return templates.RenderRSS(f, site, posts[:rssCount]) |
| 143 | 143 | }) |
| 144 | 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 | + | |
| 145 | 155 | // 9. Search index |
| 146 | 156 | GenerateSearchIndex(posts, filepath.Join(out, "search-index.json")) |
| 147 | 157 | |
| @@ -166,8 +176,15 @@ func Generate(cfg *config.Config, posts []*content.Post, pages []*content.Page) | ||
| 166 | 176 | } |
| 167 | 177 | } |
| 168 | 178 | |
| 169 | - log.Printf("Generated %d posts, %d pages, %d tag pages, %d category pages, RSS feed into %s/", |
|
| 170 | -len(posts), len(pages), len(tagMap), len(catMap), out) |
|
| 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) |
|
| 171 | 188 | } |
| 172 | 189 | |
| 173 | 190 | func writeTemplate(path string, render func(f *os.File) error) { |
| @@ -0,0 +1,48 @@ | ||
| 1 | +package generator |
|
| 2 | + | |
| 3 | +import ( |
|
| 4 | +"io" |
|
| 5 | +"log" |
|
| 6 | +"os" |
|
| 7 | +"strings" |
|
| 8 | + | |
| 9 | +"github.com/ruedigerp/newblog/internal/config" |
|
| 10 | +) |
|
| 11 | + | |
| 12 | +// RenderRobotsTxt writes a robots.txt for the site. Without explicit rules the |
|
| 13 | +// whole site is allowed; the sitemap is referenced automatically whenever |
|
| 14 | +// sitemap generation is enabled. |
|
| 15 | +func RenderRobotsTxt(w io.Writer, cfg *config.Config) error { |
|
| 16 | + bw := &errWriter{w: w} |
|
| 17 | + | |
| 18 | + bw.printf("User-agent: %s\n", cfg.Robots.UserAgentOrDefault()) |
|
| 19 | + for _, rule := range cfg.Robots.Allow { |
|
| 20 | + bw.printf("Allow: %s\n", rule) |
|
| 21 | +} |
|
| 22 | + for _, rule := range cfg.Robots.Disallow { |
|
| 23 | + bw.printf("Disallow: %s\n", rule) |
|
| 24 | +} |
|
| 25 | +// A crawler needs at least one rule; allow everything when none are configured. |
|
| 26 | + if len(cfg.Robots.Allow) == 0 && len(cfg.Robots.Disallow) == 0 { |
|
| 27 | + bw.printf("Allow: /\n") |
|
| 28 | +} |
|
| 29 | + | |
| 30 | + if cfg.Sitemap.EnabledOrDefault() && cfg.BaseURL != "" { |
|
| 31 | + bw.printf("\nSitemap: %s/sitemap.xml\n", strings.TrimRight(cfg.BaseURL, "/")) |
|
| 32 | +} |
|
| 33 | + | |
| 34 | +return bw.err |
|
| 35 | +} |
|
| 36 | + | |
| 37 | +// GenerateRobotsTxt writes robots.txt into outDir, honoring cfg.Robots.Enabled |
|
| 38 | +// (defaults to true). |
|
| 39 | +func GenerateRobotsTxt(cfg *config.Config, outDir string) { |
|
| 40 | + if !cfg.Robots.EnabledOrDefault() { |
|
| 41 | +return |
|
| 42 | +} |
|
| 43 | + if err := writeFile(outDir+"/robots.txt", func(f *os.File) error { |
|
| 44 | +return RenderRobotsTxt(f, cfg) |
|
| 45 | + }); err != nil { |
|
| 46 | + log.Printf("Error writing robots.txt: %v", err) |
|
| 47 | +} |
|
| 48 | +} |
| @@ -68,6 +68,28 @@ func Start(cfg *config.Config, posts []*content.Post, pages []*content.Page) { | ||
| 68 | 68 | } |
| 69 | 69 | }) |
| 70 | 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 | + | |
| 71 | 93 | // llms.txt — concise index for LLMs (https://llmstxt.org/) |
| 72 | 94 | if cfg.LLMs.EnabledOrDefault() { |
| 73 | 95 | mux.HandleFunc("/llms.txt", func(w http.ResponseWriter, r *http.Request) { |
| @@ -0,0 +1,169 @@ | ||
| 1 | +package templates |
|
| 2 | + | |
| 3 | +import ( |
|
| 4 | +"encoding/xml" |
|
| 5 | +"io" |
|
| 6 | +"net/url" |
|
| 7 | +"sort" |
|
| 8 | +"strconv" |
|
| 9 | +"strings" |
|
| 10 | +"time" |
|
| 11 | + | |
| 12 | +"github.com/ruedigerp/newblog/internal/content" |
|
| 13 | +) |
|
| 14 | + | |
| 15 | +const sitemapNS = "http://www.sitemaps.org/schemas/sitemap/0.9" |
|
| 16 | + | |
| 17 | +type sitemapURL struct { |
|
| 18 | +XMLName xml.Name `xml:"url"` |
|
| 19 | +Loc string `xml:"loc"` |
|
| 20 | +LastMod string `xml:"lastmod,omitempty"` |
|
| 21 | +ChangeFreq string `xml:"changefreq,omitempty"` |
|
| 22 | +Priority string `xml:"priority,omitempty"` |
|
| 23 | +} |
|
| 24 | + | |
| 25 | +type sitemapURLSet struct { |
|
| 26 | +XMLName xml.Name `xml:"urlset"` |
|
| 27 | +Xmlns string `xml:"xmlns,attr"` |
|
| 28 | +URLs []sitemapURL `xml:"url"` |
|
| 29 | +} |
|
| 30 | + | |
| 31 | +// RenderSitemap writes a sitemaps.org 0.9 XML sitemap covering the homepage, |
|
| 32 | +// its pagination pages, all posts, all static pages and the tag/category |
|
| 33 | +// listings — i.e. every URL the generator produces as HTML. |
|
| 34 | +func RenderSitemap(w io.Writer, site SiteData, posts []*content.Post, pages []*content.Page, postsPerPage int) error { |
|
| 35 | +base := strings.TrimRight(site.BaseURL, "/") |
|
| 36 | + | |
| 37 | +// Newest post date doubles as lastmod for the index pages. |
|
| 38 | +siteMod := "" |
|
| 39 | + if len(posts) > 0 { |
|
| 40 | +siteMod = sitemapDate(postModTime(posts[0])) |
|
| 41 | +} |
|
| 42 | + | |
| 43 | + urls := []sitemapURL{{ |
|
| 44 | +Loc: base + "/", |
|
| 45 | +LastMod: siteMod, |
|
| 46 | +ChangeFreq: "daily", |
|
| 47 | +Priority: "1.0", |
|
| 48 | +}} |
|
| 49 | + | |
| 50 | +// Pagination: /page/2/, /page/3/, ... (page 1 is the homepage) |
|
| 51 | + if postsPerPage > 0 { |
|
| 52 | +totalPages := (len(posts) + postsPerPage - 1) / postsPerPage |
|
| 53 | + for page := 2; page <= totalPages; page++ { |
|
| 54 | + urls = append(urls, sitemapURL{ |
|
| 55 | +Loc: base + "/page/" + strconv.Itoa(page) + "/", |
|
| 56 | +LastMod: siteMod, |
|
| 57 | +ChangeFreq: "weekly", |
|
| 58 | +Priority: "0.4", |
|
| 59 | +}) |
|
| 60 | +} |
|
| 61 | +} |
|
| 62 | + | |
| 63 | +// Posts |
|
| 64 | + for _, p := range posts { |
|
| 65 | + urls = append(urls, sitemapURL{ |
|
| 66 | +Loc: base + "/posts/" + escapePath(p.Slug) + "/", |
|
| 67 | +LastMod: sitemapDate(postModTime(p)), |
|
| 68 | +ChangeFreq: "monthly", |
|
| 69 | +Priority: "0.8", |
|
| 70 | +}) |
|
| 71 | +} |
|
| 72 | + | |
| 73 | +// Static pages |
|
| 74 | + for _, pg := range pages { |
|
| 75 | + urls = append(urls, sitemapURL{ |
|
| 76 | +Loc: base + "/sites/" + escapePath(pg.Slug) + "/", |
|
| 77 | +ChangeFreq: "monthly", |
|
| 78 | +Priority: "0.6", |
|
| 79 | +}) |
|
| 80 | +} |
|
| 81 | + | |
| 82 | +// Tag and category listings |
|
| 83 | +tagMap := content.CollectTags(posts) |
|
| 84 | +catMap := content.CollectCategories(posts) |
|
| 85 | + if len(tagMap) > 0 { |
|
| 86 | + urls = append(urls, sitemapURL{ |
|
| 87 | +Loc: base + "/tags/", |
|
| 88 | +LastMod: siteMod, |
|
| 89 | +ChangeFreq: "weekly", |
|
| 90 | +Priority: "0.5", |
|
| 91 | +}) |
|
| 92 | +urls = append(urls, taxonomyURLs(base, "tags", tagMap)...) |
|
| 93 | +} |
|
| 94 | + if len(catMap) > 0 { |
|
| 95 | + urls = append(urls, sitemapURL{ |
|
| 96 | +Loc: base + "/categories/", |
|
| 97 | +LastMod: siteMod, |
|
| 98 | +ChangeFreq: "weekly", |
|
| 99 | +Priority: "0.5", |
|
| 100 | +}) |
|
| 101 | +urls = append(urls, taxonomyURLs(base, "categories", catMap)...) |
|
| 102 | +} |
|
| 103 | + | |
| 104 | + set := sitemapURLSet{Xmlns: sitemapNS, URLs: urls} |
|
| 105 | + | |
| 106 | + if _, err := w.Write([]byte(xml.Header)); err != nil { |
|
| 107 | +return err |
|
| 108 | +} |
|
| 109 | +enc := xml.NewEncoder(w) |
|
| 110 | + enc.Indent("", " ") |
|
| 111 | + if err := enc.Encode(set); err != nil { |
|
| 112 | +return err |
|
| 113 | +} |
|
| 114 | + _, err := w.Write([]byte("\n")) |
|
| 115 | +return err |
|
| 116 | +} |
|
| 117 | + | |
| 118 | +// taxonomyURLs builds the per-tag / per-category URLs, sorted for stable output. |
|
| 119 | +func taxonomyURLs(base, prefix string, m map[string][]*content.Post) []sitemapURL { |
|
| 120 | +names := make([]string, 0, len(m)) |
|
| 121 | + for name := range m { |
|
| 122 | +names = append(names, name) |
|
| 123 | +} |
|
| 124 | +sort.Strings(names) |
|
| 125 | + | |
| 126 | +out := make([]sitemapURL, 0, len(names)) |
|
| 127 | + for _, name := range names { |
|
| 128 | +lastMod := "" |
|
| 129 | + for _, p := range m[name] { |
|
| 130 | + if d := sitemapDate(postModTime(p)); d > lastMod { |
|
| 131 | +lastMod = d |
|
| 132 | +} |
|
| 133 | +} |
|
| 134 | + out = append(out, sitemapURL{ |
|
| 135 | +// Links in the themes use the lowercased name, same as the generator's dirs. |
|
| 136 | +Loc: base + "/" + prefix + "/" + escapePath(strings.ToLower(name)) + "/", |
|
| 137 | +LastMod: lastMod, |
|
| 138 | +ChangeFreq: "weekly", |
|
| 139 | +Priority: "0.4", |
|
| 140 | +}) |
|
| 141 | +} |
|
| 142 | +return out |
|
| 143 | +} |
|
| 144 | + | |
| 145 | +// postModTime prefers the update date over the publication date. |
|
| 146 | +func postModTime(p *content.Post) time.Time { |
|
| 147 | + if !p.Update.Time.IsZero() { |
|
| 148 | +return p.Update.Time |
|
| 149 | +} |
|
| 150 | +return p.Date.Time |
|
| 151 | +} |
|
| 152 | + | |
| 153 | +// sitemapDate formats a W3C date (YYYY-MM-DD); zero times yield "". |
|
| 154 | +func sitemapDate(t time.Time) string { |
|
| 155 | + if t.IsZero() { |
|
| 156 | +return "" |
|
| 157 | +} |
|
| 158 | + return t.Format("2006-01-02") |
|
| 159 | +} |
|
| 160 | + | |
| 161 | +// escapePath percent-encodes each segment of a slug so tags with spaces or |
|
| 162 | +// umlauts stay valid URLs. |
|
| 163 | +func escapePath(slug string) string { |
|
| 164 | +parts := strings.Split(slug, "/") |
|
| 165 | + for i, part := range parts { |
|
| 166 | +parts[i] = url.PathEscape(part) |
|
| 167 | +} |
|
| 168 | +return strings.Join(parts, "/") |
|
| 169 | +} |