serve robots.txt and sitemap.xml
Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases — no bundle can collide, since a key always sits under a section. robots.txt at the site root is served verbatim, because a site that ships one has said something deliberate; otherwise the engine emits the minimum that is true and points at the sitemap. The sitemap lists every bundle in every language it exists in, since each variant is separately reachable, with lastmod only where a bundle has a date. Every URL comes from content.URL like every other path the engine emits, so a sitemap cannot disagree with what is actually served. Both need a declared base. Without one the sitemap answers 404 rather than listing paths no crawler can resolve, and robots omits the Sitemap line rather than writing a relative one. write() was setting text/html for every caller, and headers only go out with the first byte — so a handler setting its own type would have had it silently replaced, which is how a sitemap gets served as a web page. It now splits into write and writeAs, and the tests assert the content types rather than only the bodies.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"khosra/internal/content"
|
||||
)
|
||||
|
||||
// robots and sitemap are the two files a crawler looks for by exact name.
|
||||
const (
|
||||
robotsPath = "/robots.txt"
|
||||
sitemapPath = "/sitemap.xml"
|
||||
)
|
||||
|
||||
// serveRobots answers /robots.txt, preferring the site's own file.
|
||||
//
|
||||
// A site that ships robots.txt has said something deliberate, so it is served verbatim; otherwise the engine
|
||||
// emits the minimum that is true — everything is public, and here is the sitemap. The Sitemap line only
|
||||
// appears with a declared base, because a relative sitemap reference is not something a crawler accepts.
|
||||
func serveRobots(w http.ResponseWriter, req *http.Request, siteFS fs.FS, base string) {
|
||||
if siteFS != nil {
|
||||
if data, err := fs.ReadFile(siteFS, "robots.txt"); err == nil {
|
||||
writeAs(w, "text/plain; charset=utf-8", data, "robots.txt")
|
||||
return
|
||||
}
|
||||
}
|
||||
var out strings.Builder
|
||||
out.WriteString("User-agent: *\nDisallow:\n")
|
||||
if base != "" {
|
||||
fmt.Fprintf(&out, "Sitemap: %s\n", content.Absolute(base, sitemapPath))
|
||||
}
|
||||
writeAs(w, "text/plain; charset=utf-8", []byte(out.String()), "robots.txt")
|
||||
}
|
||||
|
||||
// serveSitemap answers /sitemap.xml with every bundle in every language it exists in.
|
||||
//
|
||||
// It needs a declared base: the sitemap format has no room for a relative URL, so without one the honest
|
||||
// answer is that this file does not exist rather than a file full of paths no crawler can use (ADR-0039).
|
||||
// Every URL comes from content.URL, like every other path the engine emits, so a sitemap can never disagree
|
||||
// with what is actually served.
|
||||
func serveSitemap(w http.ResponseWriter, req *http.Request, site *content.Site, base string) {
|
||||
if base == "" {
|
||||
slog.Warn("no sitemap: the site declares no base URL", "file", content.SettingsFile)
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
var out strings.Builder
|
||||
out.WriteString(`<?xml version="1.0" encoding="utf-8"?>` + "\n")
|
||||
out.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` + "\n")
|
||||
for _, entry := range site.Everything() {
|
||||
fmt.Fprintf(&out, "<url><loc>%s</loc>", xmlEscape(content.Absolute(base, content.URL(entry.Key, entry.Lang))))
|
||||
if !entry.Date.IsZero() {
|
||||
fmt.Fprintf(&out, "<lastmod>%s</lastmod>", entry.Date.Format("2006-01-02"))
|
||||
}
|
||||
out.WriteString("</url>\n")
|
||||
}
|
||||
out.WriteString("</urlset>\n")
|
||||
writeAs(w, "application/xml; charset=utf-8", []byte(out.String()), "sitemap.xml")
|
||||
}
|
||||
|
||||
// xmlEscape escapes the five characters XML reserves. A URL should contain none of them, and a sitemap that
|
||||
// silently breaks on the one that does is worse than a slightly paranoid replacement.
|
||||
func xmlEscape(s string) string {
|
||||
return strings.NewReplacer(
|
||||
"&", "&", "<", "<", ">", ">", `"`, """, "'", "'",
|
||||
).Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
func crawlerHandler(t *testing.T, settings content.Settings, extra fstest.MapFS) http.Handler {
|
||||
t.Helper()
|
||||
fsys := fstest.MapFS{
|
||||
"content/posts/hello.md": {Data: []byte("---\ntitle: Hello\ndate: 2026-03-08\n---\nx\n")},
|
||||
"content/posts/hello.bn.md": {Data: []byte("---\ntitle: হ্যালো\ndate: 2026-03-08\n---\nx\n")},
|
||||
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nx\n")},
|
||||
}
|
||||
for name, file := range extra {
|
||||
fsys[name] = file
|
||||
}
|
||||
bundles, err := content.Scan(fsys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(nil, settings, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, settings)
|
||||
}
|
||||
|
||||
func TestSitemapListsEveryVariantAbsolutely(t *testing.T) {
|
||||
h := crawlerHandler(t, content.Settings{Base: "https://khosra.example"}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/xml") {
|
||||
t.Errorf("content-type = %q — a sitemap served as HTML is a sitemap nothing reads", ct)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"<loc>https://khosra.example/posts/hello/</loc>",
|
||||
"<loc>https://khosra.example/bn/posts/hello/</loc>", // each language is its own URL
|
||||
"<loc>https://khosra.example/pages/about/</loc>",
|
||||
"<lastmod>2026-03-08</lastmod>",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<lastmod></lastmod>") {
|
||||
t.Error("an undated bundle should carry no lastmod at all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoBaseMeansNoSitemap(t *testing.T) {
|
||||
// The format has no room for a relative URL, so the honest answer is that the file does not exist
|
||||
// (ADR-0039) rather than one full of paths no crawler can resolve.
|
||||
h := crawlerHandler(t, content.Settings{}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobotsIsGeneratedOrTheSitesOwn(t *testing.T) {
|
||||
h := crawlerHandler(t, content.Settings{Base: "https://khosra.example"}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "User-agent: *") || !strings.Contains(body, "Sitemap: https://khosra.example/sitemap.xml") {
|
||||
t.Errorf("generated robots should point at the sitemap:\n%s", body)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
||||
t.Errorf("content-type = %q", ct)
|
||||
}
|
||||
|
||||
// A site that ships its own has said something deliberate.
|
||||
h = crawlerHandler(t, content.Settings{Base: "https://khosra.example"},
|
||||
fstest.MapFS{"robots.txt": {Data: []byte("User-agent: *\nDisallow: /drafts/\n")}})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
|
||||
if got := rec.Body.String(); !strings.Contains(got, "Disallow: /drafts/") || strings.Contains(got, "Sitemap:") {
|
||||
t.Errorf("the site's own robots.txt should be served verbatim:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobotsWithoutABaseOmitsTheSitemapLine(t *testing.T) {
|
||||
h := crawlerHandler(t, content.Settings{}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
|
||||
if got := rec.Body.String(); strings.Contains(got, "Sitemap:") {
|
||||
t.Errorf("a relative sitemap reference is not something a crawler accepts:\n%s", got)
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -15,11 +15,19 @@ import (
|
||||
// Handler serves a site.
|
||||
//
|
||||
// One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
|
||||
func Handler(site *content.Site, r *render.Renderer, siteFS fs.FS) http.Handler {
|
||||
func Handler(site *content.Site, r *render.Renderer, siteFS fs.FS, settings content.Settings) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
|
||||
serve(w, req, site, r)
|
||||
})
|
||||
// Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases: no
|
||||
// bundle can own them, since a key always sits under a section.
|
||||
mux.HandleFunc("GET "+robotsPath, func(w http.ResponseWriter, req *http.Request) {
|
||||
serveRobots(w, req, siteFS, settings.Base)
|
||||
})
|
||||
mux.HandleFunc("GET "+sitemapPath, func(w http.ResponseWriter, req *http.Request) {
|
||||
serveSitemap(w, req, site, settings.Base)
|
||||
})
|
||||
if siteFS != nil {
|
||||
if sub, err := fs.Sub(siteFS, "static"); err == nil {
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", serveStatic(sub)))
|
||||
@@ -98,7 +106,16 @@ func serveTags(w http.ResponseWriter, req *http.Request, site *content.Site, r *
|
||||
|
||||
// write sends a rendered page, logging a failed write rather than pretending it succeeded.
|
||||
func write(w http.ResponseWriter, out []byte, what string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
writeAs(w, "text/html; charset=utf-8", out, what)
|
||||
}
|
||||
|
||||
// writeAs sends bytes with the type they actually are.
|
||||
//
|
||||
// Separate from write because headers are only sent with the first byte, so a handler that set its own type
|
||||
// before calling write would have had it silently replaced by HTML — which is how a sitemap ends up served
|
||||
// as a web page.
|
||||
func writeAs(w http.ResponseWriter, contentType string, out []byte, what string) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
if _, err := w.Write(out); err != nil {
|
||||
slog.Warn("write failed", "what", what, "err", err)
|
||||
}
|
||||
|
||||
+16
-16
@@ -24,11 +24,11 @@ func testHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(nil, nil)
|
||||
r, err := render.New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys)
|
||||
return Handler(content.NewSite(bundles), r, fsys, content.Settings{})
|
||||
}
|
||||
|
||||
func TestServeBundleAtItsPermalink(t *testing.T) {
|
||||
@@ -72,11 +72,11 @@ func multilingualHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(nil, nil)
|
||||
r, err := render.New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys)
|
||||
return Handler(content.NewSite(bundles), r, fsys, content.Settings{})
|
||||
}
|
||||
|
||||
func TestPrefixedLanguageServesThatVariant(t *testing.T) {
|
||||
@@ -124,11 +124,11 @@ func aliasHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(nil, nil)
|
||||
r, err := render.New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys)
|
||||
return Handler(content.NewSite(bundles), r, fsys, content.Settings{})
|
||||
}
|
||||
|
||||
func TestAliasRedirectsToCanonical(t *testing.T) {
|
||||
@@ -171,11 +171,11 @@ func listingHandler(t *testing.T, n int) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(nil, nil)
|
||||
r, err := render.New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys)
|
||||
return Handler(content.NewSite(bundles), r, fsys, content.Settings{})
|
||||
}
|
||||
|
||||
func TestSectionIndexListsNewestFirst(t *testing.T) {
|
||||
@@ -239,11 +239,11 @@ func TestStaticFilesAreServedAndDirectoriesAreNot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(fsys, nil)
|
||||
r, err := render.New(fsys, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler(content.NewSite(bundles), r, fsys)
|
||||
h := Handler(content.NewSite(bundles), r, fsys, content.Settings{})
|
||||
for path, want := range map[string]int{
|
||||
"/static/style.css": http.StatusOK,
|
||||
"/static/img/logo.svg": http.StatusOK,
|
||||
@@ -280,11 +280,11 @@ func TestAStaticPathThatEscapesTheRootIs404(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(fsys, nil)
|
||||
r, err := render.New(fsys, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler(content.NewSite(nil), r, fsys)
|
||||
h := Handler(content.NewSite(nil), r, fsys, content.Settings{})
|
||||
for path, want := range map[string]int{
|
||||
"/static/ok.css": http.StatusOK,
|
||||
"/static/escape.txt": http.StatusNotFound,
|
||||
@@ -313,11 +313,11 @@ func seriesHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(nil, nil)
|
||||
r, err := render.New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, nil)
|
||||
return Handler(content.NewSite(bundles), r, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestSequenceNavigationLinksNeighbours(t *testing.T) {
|
||||
@@ -403,11 +403,11 @@ func tagHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(nil, nil)
|
||||
r, err := render.New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, nil)
|
||||
return Handler(content.NewSite(bundles), r, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestGlobalTagListingSpansSectionsGroupedByOne(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user