LabCodeHub öffentliche Ansicht
Anmelden
Küpper / Quildrop öffentlich

fix: add sitemap and robots.txt

R Rüdiger Küpper <rpr@9it.de> committete am 18.08.2026 04:17
89f2d506160faa065851bc8d95d24aca64dbd65b
8 geänderte Datei(en) +431 −20
Kontextzeilen: 0 1 2 3 10
geändert README-de.md
+52 −2 Datei ansehen
@@ -183,2 +183,43 @@ Automatisch generierter RSS 2.0 Feed unter `/index.xml` mit:
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
@@ -237,2 +278,3 @@ quilldrop/
237 278
│   │   ├── generator.go             # Static Site Generator
279 +
│   │   ├── robots.go                # robots.txt Generator
238 280
│   │   └── search.go                # Search-Index Generator (JSON)
@@ -240,3 +282,4 @@ quilldrop/
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
@@ -358,3 +401,3 @@ menu:
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 |
@@ -368,2 +411,7 @@ menu:
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
@@ -452,2 +500,4 @@ Alle URLs verwenden konsequent Trailing Slashes, um serverseitige Redirects zu v
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) |
geändert README.md
+52 −2 Datei ansehen
@@ -185,2 +185,43 @@ Automatically generated RSS 2.0 feed at `/index.xml` with:
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
@@ -239,2 +280,3 @@ quilldrop/
239 280
│   │   ├── generator.go             # Static site generator
281 +
│   │   ├── robots.go                # robots.txt generator
240 282
│   │   └── search.go                # Search index generator (JSON)
@@ -242,3 +284,4 @@ quilldrop/
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
@@ -358,3 +401,3 @@ menu:
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 |
@@ -368,2 +411,7 @@ menu:
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
@@ -452,2 +500,4 @@ All URLs consistently use trailing slashes to avoid server-side redirects:
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) |
geändert config.yaml
+13 −0 Datei ansehen
@@ -15,2 +15,15 @@ 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:
geändert internal/config/config.go
+56 −14 Datei ansehen
@@ -38,17 +38,59 @@ func (l LLMsConfig) FullOrDefault() bool {
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
}
geändert internal/generator/generator.go
+19 −2 Datei ansehen
@@ -144,2 +144,12 @@ func Generate(cfg *config.Config, posts []*content.Post, pages []*content.Page)
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
@@ -168,4 +178,11 @@ func Generate(cfg *config.Config, posts []*content.Post, pages []*content.Page)
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
}
hinzugefügt internal/generator/robots.go
+48 −0 Datei ansehen
@@ -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 +
}
geändert internal/server/server.go
+22 −0 Datei ansehen
@@ -70,2 +70,24 @@ func Start(cfg *config.Config, posts []*content.Post, pages []*content.Page) {
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/)
hinzugefügt internal/templates/sitemap.go
+169 −0 Datei ansehen
@@ -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 +
}