this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Add sitemap.xml, robots.txt, and canonical URLs

- Add dynamic sitemap generation at /sitemap.xml
- Includes homepage, /stats, /search
- Includes date-based archive pages for last 30 days
- 1-hour cache for performance

- Add dynamic robots.txt with sitemap reference

- Add canonical URL support to all pages
- Index pages use date-based permalinks for stable canonicals
- Stats and search pages include canonical tags
- User filter pages (?poster=) get proper canonicals

- Add date-based URL support for index pages
- New params: ?from=YYYY-MM-DD&to=YYYY-MM-DD
- Provides stable permalinks for archived content
- Falls back to page-based pagination (?i=N) if not specified

+196 -9
+4 -1
cmd/tumble/main.go
··· 185 185 mux.HandleFunc("/quote/", h.QuoteHandler) 186 186 mux.HandleFunc("/quote/index.cgi", h.QuoteHandler) 187 187 188 + // SEO Routes 189 + mux.HandleFunc("/sitemap.xml", h.SitemapHandler) 190 + mux.HandleFunc("/robots.txt", h.RobotsHandler) 191 + 188 192 // Static Assets 189 193 // Serve from embedded FS 190 194 // "/css/" -> internal/assets/css ··· 194 198 // mux.Handle("/buttons/", fileServer) // Removed in favor of ButtonHandler check 195 199 mux.Handle("/favicon.ico", fileServer) 196 200 // Legacy static files 197 - mux.Handle("/robots.txt", fileServer) 198 201 mux.Handle("/apple-touch-icon.png", fileServer) 199 202 subFS, _ := fs.Sub(assets.StaticFS, "2202") 200 203 mux.Handle("/2202/", http.StripPrefix("/2202/", http.FileServer(http.FS(subFS))))
+105 -8
internal/handler/handlers.go
··· 6 6 "html/template" 7 7 "log/slog" 8 8 "net/http" 9 + "net/url" 9 10 "sort" 10 11 "strconv" 11 12 "sync" ··· 59 60 Poster string 60 61 FilterType string 61 62 IsFallbackContent bool 63 + CanonicalURL string 62 64 } 63 65 64 66 // NavigationData holds pagination navigation info ··· 131 133 params := r.URL.Query() 132 134 dtype := params.Get("dtype") 133 135 iParam := params.Get("i") 136 + fromParam := params.Get("from") 137 + toParam := params.Get("to") 134 138 135 139 // Infer dtype from path if not set 136 140 if dtype == "" { ··· 139 143 } 140 144 } 141 145 142 - i := 1 143 - if iParam != "" { 144 - val, err := strconv.Atoi(iParam) 145 - if err == nil && val > 0 { 146 - i = val 146 + var startDays, endDays int 147 + var i int = 1 148 + var usingDateRange bool 149 + 150 + // Check for date-based URL parameters first 151 + if fromParam != "" && toParam != "" { 152 + fromDate, fromErr := time.Parse("2006-01-02", fromParam) 153 + toDate, toErr := time.Parse("2006-01-02", toParam) 154 + if fromErr == nil && toErr == nil { 155 + // Convert dates to days offset from today 156 + now := time.Now().Truncate(24 * time.Hour) 157 + startDays = int(now.Sub(fromDate).Hours() / 24) 158 + endDays = int(now.Sub(toDate).Hours() / 24) 159 + usingDateRange = true 147 160 } 148 161 } 149 162 150 - // Date interval logic 151 - startDays := i * 6 152 - endDays := (i - 1) * 6 163 + // Fall back to page-based pagination if no valid date range 164 + if !usingDateRange { 165 + if iParam != "" { 166 + val, err := strconv.Atoi(iParam) 167 + if err == nil && val > 0 { 168 + i = val 169 + } 170 + } 171 + // Date interval logic based on page number 172 + startDays = i * 6 173 + endDays = (i - 1) * 6 174 + } 153 175 154 176 // Fetch Items 155 177 var wg sync.WaitGroup ··· 338 360 pageTitle = fmt.Sprintf(" &gt; Links by %s", poster) 339 361 } 340 362 363 + // Build canonical URL - use date params if provided, otherwise convert from page number 364 + var canonicalURL string 365 + if usingDateRange { 366 + canonicalURL = h.buildCanonicalURL(i, poster, filterType, fromParam, toParam) 367 + } else { 368 + canonicalURL = h.buildCanonicalURL(i, poster, filterType, "", "") 369 + } 370 + 341 371 viewData := IndexPageData{ 342 372 PageTitle: pageTitle, 343 373 Container: template.HTML(containerHTML), ··· 350 380 GitCommit: version.CommitHash, 351 381 GitCommitURL: fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), 352 382 IsFallbackContent: isFallback, 383 + CanonicalURL: canonicalURL, 353 384 } 354 385 355 386 templateName := "index.html" ··· 392 423 return navResult{prevHTML: prevHTML, nextHTML: nextHTML} 393 424 } 394 425 426 + // pageToDateRange converts a relative page number to a date range. 427 + // Page 1 is the most recent 6 days, page 2 is days 7-12, etc. 428 + func pageToDateRange(page int) (from, to time.Time) { 429 + now := time.Now().Truncate(24 * time.Hour) 430 + startDays := page * 6 431 + endDays := (page - 1) * 6 432 + from = now.AddDate(0, 0, -startDays) 433 + to = now.AddDate(0, 0, -endDays) 434 + return from, to 435 + } 436 + 437 + // buildCanonicalURL constructs the canonical URL for the current page. 438 + // If fromDate and toDate are provided (non-empty), they are used directly. 439 + // Otherwise, the page number is converted to a date range. 440 + func (h *Handler) buildCanonicalURL(page int, poster, filterType, fromDate, toDate string) string { 441 + base := h.Config.BaseURL 442 + 443 + // User filter pages - canonical is the filter URL itself 444 + if poster != "" { 445 + canonical := base + "/?poster=" + url.QueryEscape(poster) 446 + if filterType != "" { 447 + canonical += "&type=" + url.QueryEscape(filterType) 448 + } 449 + return canonical 450 + } 451 + 452 + // If date range was explicitly provided, use it as-is (self-referential canonical) 453 + if fromDate != "" && toDate != "" { 454 + return base + "/?from=" + fromDate + "&to=" + toDate 455 + } 456 + 457 + // Homepage (page 1) - canonical is root 458 + if page <= 1 { 459 + return base + "/" 460 + } 461 + 462 + // Older pages - convert to date-based canonical 463 + from, to := pageToDateRange(page) 464 + return base + "/?from=" + from.Format("2006-01-02") + "&to=" + to.Format("2006-01-02") 465 + } 466 + 467 + // buildStatsCanonicalURL constructs the canonical URL for stats pages. 468 + func (h *Handler) buildStatsCanonicalURL(view, sortBy string, page int) string { 469 + base := h.Config.BaseURL + "/stats" 470 + params := url.Values{} 471 + 472 + // Only include non-default values 473 + if view != "" && view != "users" { 474 + params.Set("view", view) 475 + } 476 + if sortBy != "" && sortBy != "links" { 477 + params.Set("sort", sortBy) 478 + } 479 + if page > 1 { 480 + params.Set("page", strconv.Itoa(page)) 481 + } 482 + 483 + if len(params) > 0 { 484 + return base + "?" + params.Encode() 485 + } 486 + return base 487 + } 488 + 395 489 func (h *Handler) ButtonHandler(w http.ResponseWriter, r *http.Request) { 396 490 user := r.FormValue("user") 397 491 data := map[string]interface{}{ ··· 445 539 BaseURL: template.HTML(h.Config.BaseURL), 446 540 GitCommit: version.CommitHash, 447 541 GitCommitURL: fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), 542 + CanonicalURL: h.Config.BaseURL + "/search?search=" + url.QueryEscape(query), 448 543 } 449 544 450 545 w.Header().Set("Content-Type", "text/html; charset=UTF-8") ··· 526 621 "View": view, 527 622 "Hot": h.getHotHTML(ctx), 528 623 "BaseURL": h.Config.BaseURL, 624 + "CanonicalURL": h.buildStatsCanonicalURL(view, "", page), 529 625 } 530 626 } else { 531 627 sortBy := r.URL.Query().Get("sort") ··· 571 667 "View": view, 572 668 "Hot": h.getHotHTML(ctx), 573 669 "BaseURL": h.Config.BaseURL, 670 + "CanonicalURL": h.buildStatsCanonicalURL(view, sortBy, page), 574 671 } 575 672 } 576 673
+85
internal/handler/sitemap.go
··· 1 + package handler 2 + 3 + import ( 4 + "encoding/xml" 5 + "fmt" 6 + "net/http" 7 + ) 8 + 9 + // SitemapURLSet represents the root element of a sitemap XML. 10 + type SitemapURLSet struct { 11 + XMLName xml.Name `xml:"urlset"` 12 + XMLNS string `xml:"xmlns,attr"` 13 + URLs []SitemapURL `xml:"url"` 14 + } 15 + 16 + // SitemapURL represents a single URL entry in a sitemap. 17 + type SitemapURL struct { 18 + Loc string `xml:"loc"` 19 + LastMod string `xml:"lastmod,omitempty"` 20 + ChangeFreq string `xml:"changefreq,omitempty"` 21 + Priority string `xml:"priority,omitempty"` 22 + } 23 + 24 + // SitemapHandler generates a dynamic sitemap.xml. 25 + func (h *Handler) SitemapHandler(w http.ResponseWriter, r *http.Request) { 26 + baseURL := h.Config.BaseURL 27 + urls := []SitemapURL{} 28 + 29 + // Homepage - highest priority, changes frequently 30 + urls = append(urls, SitemapURL{ 31 + Loc: baseURL + "/", 32 + ChangeFreq: "hourly", 33 + Priority: "1.0", 34 + }) 35 + 36 + // Static pages 37 + urls = append(urls, SitemapURL{ 38 + Loc: baseURL + "/stats", 39 + ChangeFreq: "daily", 40 + Priority: "0.5", 41 + }) 42 + 43 + urls = append(urls, SitemapURL{ 44 + Loc: baseURL + "/search", 45 + ChangeFreq: "weekly", 46 + Priority: "0.3", 47 + }) 48 + 49 + // Date-based archive pages for last 30 days (~5 pages of 6 days each) 50 + // Skip page 1 since homepage already covers it 51 + for page := 2; page <= 5; page++ { 52 + from, to := pageToDateRange(page) 53 + 54 + // Calculate priority - decreases with age 55 + priority := fmt.Sprintf("%.1f", 0.9-float64(page-2)*0.1) 56 + 57 + urls = append(urls, SitemapURL{ 58 + Loc: baseURL + "/?from=" + from.Format("2006-01-02") + "&to=" + to.Format("2006-01-02"), 59 + LastMod: to.Format("2006-01-02"), 60 + ChangeFreq: "weekly", 61 + Priority: priority, 62 + }) 63 + } 64 + 65 + sitemap := SitemapURLSet{ 66 + XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9", 67 + URLs: urls, 68 + } 69 + 70 + w.Header().Set("Content-Type", "application/xml; charset=UTF-8") 71 + w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour 72 + 73 + w.Write([]byte(xml.Header)) 74 + enc := xml.NewEncoder(w) 75 + enc.Indent("", " ") 76 + if err := enc.Encode(sitemap); err != nil { 77 + http.Error(w, "Error generating sitemap", http.StatusInternalServerError) 78 + } 79 + } 80 + 81 + // RobotsHandler serves a dynamic robots.txt that includes the sitemap URL. 82 + func (h *Handler) RobotsHandler(w http.ResponseWriter, r *http.Request) { 83 + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 84 + fmt.Fprintf(w, "User-agent: *\nDisallow:\n\nSitemap: %s/sitemap.xml\n", h.Config.BaseURL) 85 + }
+1
internal/templates/views/index.html
··· 9 9 /> 10 10 <link rel="preload" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block" as="style" /> 11 11 <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block" /> 12 + {{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}" />{{end}} 12 13 13 14 <!-- Theme Init --> 14 15 <script>
+1
internal/templates/views/stats.html
··· 4 4 <title>tumblefish.stats</title> 5 5 <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" /> 6 6 <link rel="stylesheet" href="/css/screen.css" type="text/css" media="screen" /> 7 + {{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}" />{{end}} 7 8 <style> 8 9 9 10