diff --git a/blog/blog.go b/blog/blog.go index 09b11ac..e6cd21f 100644 --- a/blog/blog.go +++ b/blog/blog.go @@ -28,7 +28,6 @@ import ( "github.com/joho/godotenv" "gorm.io/gorm" - "github.com/ikeikeikeike/go-sitemap-generator/v2/stm" ) // PageFilter is a function that decides whether a page should be shown. @@ -1229,17 +1228,60 @@ func (b *Blog) SiteURL(c *gin.Context) string { return scheme + "://" + c.Request.Host } -// Sitemap serves /sitemap.xml: the home page, every enabled page (nav or -// not), post type listings, published posts (with lastmod), tags in use, -// and whatever URLs enabled plugins list for the pages under their slugs. -// Every loc is under SiteURL. +type sitemapURLSet struct { + XMLName xml.Name `xml:"urlset"` + Xmlns string `xml:"xmlns,attr"` + URLs []sitemapEntry `xml:"url"` +} + +type sitemapEntry struct { + Loc string `xml:"loc"` + LastMod string `xml:"lastmod,omitempty"` +} + +// Sitemap serves /sitemap.xml: the home page, post type listings, every +// enabled page (nav or not), published posts, and whatever URLs enabled +// plugins list for the pages under their slugs. Tag pages are left out: +// they only re-list posts that are already here. Every loc is under +// SiteURL and appears once. lastmod is the date the content last changed +// — for the home page or a listing, that of its newest post — and is left +// out when that is not known, never filled in with the time of the request. func (b *Blog) Sitemap(c *gin.Context) { host := b.SiteURL(c) - sm := stm.NewSitemap(1) - sm.SetDefaultHost(host) - sm.Create() + set := sitemapURLSet{Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9"} + seen := map[string]bool{} + add := func(path string, lastMod time.Time) { + if seen[path] { + return + } + seen[path] = true + entry := sitemapEntry{Loc: host + path} + if !lastMod.IsZero() { + entry.LastMod = lastMod.UTC().Format(time.RFC3339) + } + set.URLs = append(set.URLs, entry) + } + newer := func(a, b time.Time) time.Time { + if b.After(a) { + return b + } + return a + } + + posts := b.GetPosts(false) + var newest time.Time + byType := map[uint]time.Time{} + for _, post := range posts { + newest = newer(newest, post.UpdatedAt) + byType[post.PostTypeID] = newer(byType[post.PostTypeID], post.UpdatedAt) + } - sm.Add(stm.URL{{"loc", "/"}, {"changefreq", "weekly"}, {"priority", 1.0}}) + add("/", newest) + // Before pages: a listing usually has a page at the same path, and the + // listing's date (its newest post) is the one that says when it changed. + for _, pt := range b.GetPostTypes() { + add(pt.Permalink(), byType[pt.ID]) + } var pages []Page (*b.db).Where("enabled = ?", true).Order("nav_order asc").Find(&pages) @@ -1247,32 +1289,24 @@ func (b *Blog) Sitemap(c *gin.Context) { if b.PageFilter != nil && !b.PageFilter(page) { continue } - sm.Add(stm.URL{{"loc", page.PagePermalink()}, {"changefreq", "weekly"}, {"priority", 0.7}}) + add(page.PagePermalink(), page.UpdatedAt) } - for _, pt := range b.GetPostTypes() { - sm.Add(stm.URL{{"loc", pt.Permalink()}, {"changefreq", "weekly"}, {"priority", 0.7}}) - } - - for _, post := range b.GetPosts(false) { - sm.Add(stm.URL{{"loc", post.Permalink()}, {"lastmod", post.UpdatedAt}, {"changefreq", "yearly"}, {"priority", 0.55}}) - } - for _, tag := range b.getTags() { - if len(tag.Posts) > 0 { - sm.Add(stm.URL{{"loc", tag.Permalink()}, {"changefreq", "weekly"}, {"priority", 0.55}}) - } + for _, post := range posts { + add(post.Permalink(), post.UpdatedAt) } if r := pluginRegistryFrom(c); r != nil { for _, u := range r.SitemapURLs(c) { - entry := stm.URL{{"loc", u.Loc}, {"changefreq", "weekly"}, {"priority", 0.6}} - if !u.LastMod.IsZero() { - entry = append(entry, []interface{}{"lastmod", u.LastMod}) - } - sm.Add(entry) + add(u.Loc, u.LastMod) } } - c.Data(http.StatusOK, "application/xml; charset=utf-8", sm.XMLContent()) + out, err := xml.Marshal(set) + if err != nil { + c.String(http.StatusInternalServerError, "sitemap: %v", err) + return + } + c.Data(http.StatusOK, "application/xml; charset=utf-8", append([]byte(xml.Header), out...)) } // feedItems is how many posts the RSS feed carries. diff --git a/blog/blog_test.go b/blog/blog_test.go index 3f3f638..615b5b4 100644 --- a/blog/blog_test.go +++ b/blog/blog_test.go @@ -1550,9 +1550,10 @@ func (p *sitemapPlugin) Sitemap(_ *plugin.HookContext) []plugin.SitemapURL { } // TestSitemap: every URL is under the site_url setting (not a host baked -// into goblog); posts carry lastmod; every enabled page is listed, nav or -// not; pages that do not exist are not invented; enabled plugins that -// implement plugin.Sitemapper add their URLs. +// into goblog) and listed once; posts carry lastmod, and the home page and +// a listing carry their newest post's; tag pages are left out; every +// enabled page is listed, nav or not; pages that do not exist are not +// invented; enabled plugins that implement plugin.Sitemapper add their URLs. func TestSitemap(t *testing.T) { db, _ := gorm.Open(sqlite.Open(":memory:")) db.AutoMigrate(&auth.BlogUser{}, &blog.PostType{}, &blog.Post{}, &blog.Tag{}, &blog.Comment{}, &blog.Page{}, &blog.Setting{}, &plugin.PluginSetting{}) @@ -1562,6 +1563,7 @@ func TestSitemap(t *testing.T) { updated := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC) db.Create(&blog.Post{Title: "Hello", Slug: "hello", Content: "hi", PostTypeID: pt.ID, CreatedAt: updated, UpdatedAt: updated, Tags: []blog.Tag{{Name: "go"}}}) db.Create(&blog.Post{Title: "Draft", Slug: "draft", Content: "hi", PostTypeID: pt.ID, Draft: true}) + db.Create(&blog.Page{Title: "Writing", Slug: "posts", PageType: blog.PageTypeAbout, ShowInNav: true, Enabled: true}) db.Create(&blog.Page{Title: "About", Slug: "about", PageType: blog.PageTypeAbout, ShowInNav: true, Enabled: true}) db.Create(&blog.Page{Title: "Hidden", Slug: "hidden", PageType: blog.PageTypeAbout, ShowInNav: false, Enabled: true}) db.Create(&blog.Page{Title: "Off", Slug: "off", PageType: blog.PageTypeAbout, ShowInNav: true, Enabled: false}) @@ -1584,13 +1586,13 @@ func TestSitemap(t *testing.T) { t.Fatalf("code=%d type=%q", w.Code, w.Header().Get("Content-Type")) } for _, want := range []string{ - "https://www.example.test/", + "https://www.example.test/2026-08-15T12:00:00Z", + "https://www.example.test/posts2026-08-15T12:00:00Z", "https://www.example.test/about", "https://www.example.test/hidden", "https://www.example.test/posts", "https://www.example.test/posts/2026/08/15/hello", "2026-08-15", - "https://www.example.test/tag/go", "https://www.example.test/dir", "https://www.example.test/dir/hello", "2026-09-01", @@ -1599,11 +1601,14 @@ func TestSitemap(t *testing.T) { t.Errorf("sitemap missing %q in:\n%s", want, body) } } - for _, gone := range []string{"jasonernst.com", "/off<", "/draft<", "/archives<", "/tags<"} { + for _, gone := range []string{"jasonernst.com", "/off<", "/draft<", "/archives<", "/tags<", "/tag/"} { if strings.Contains(body, gone) { t.Errorf("sitemap must not contain %q:\n%s", gone, body) } } + if n := strings.Count(body, "https://www.example.test/posts"); n != 1 { + t.Errorf("/posts listed %d times, want 1:\n%s", n, body) + } // Without site_url the request's own host is used. db.Where("key = ?", "site_url").Delete(&blog.Setting{}) diff --git a/go.mod b/go.mod index ae5a849..41f590d 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/gin-contrib/static v1.1.8 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 - github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.12.1 gorm.io/driver/mysql v1.6.0 @@ -30,13 +29,11 @@ require ( require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/beevik/etree v1.1.0 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.2 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cloudwego/base64x v0.1.7 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect - github.com/fatih/structs v1.1.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.1 // indirect github.com/go-playground/locales v0.14.1 // indirect diff --git a/go.sum b/go.sum index 3680375..6a04b47 100644 --- a/go.sum +++ b/go.sum @@ -2,16 +2,12 @@ filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= -github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/clbanning/mxj v1.8.3 h1:2r/KCJi52w2MRz+K+UMa/1d7DdCjnLqYJfnbr7dYNWI= -github.com/clbanning/mxj v1.8.3/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -21,8 +17,6 @@ github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= -github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= -github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sessions v1.1.2 h1:utWRqOHT/kbqxxqF/mtutV9wfGLi1+uu5YuvWUhj/Ow= @@ -66,8 +60,6 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw= github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= -github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM= -github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=