From c0ce1128b5b2441707dddae11e345c867b2b1ea5 Mon Sep 17 00:00:00 2001 From: Harry Day Date: Sat, 20 Jun 2026 19:25:24 +0100 Subject: [PATCH 1/7] feat(generator): add RSS 2.0 and Atom feed generation (#80) Feeds are generated automatically when a base URL is configured via WithBaseURL. Without a base URL the build succeeds and emits an info log rather than failing. Features: - Site-wide rss.xml and atom.xml at the output root - Per-tag .rss.xml and .atom.xml under tags/ - Full post HTML content in every feed item - Post URL used as the item's unique identifier (guid/id) - Configurable post limit (default 10, newest-first) - WithDisableFeeds() to opt out entirely - FeedsEnabled flag on BaseData controls template discovery links and visible RSS nav link in the default theme - New config options: WithBaseURL, WithDisableFeeds, WithFeedPostLimit - CLI flags: --base-url, --disable-feeds, --feed-limit Uses github.com/gorilla/feeds for RSS/Atom serialisation. Closes #80 --- README.md | 3 + go.mod | 1 + go.sum | 8 + integration/go.mod | 1 + integration/go.sum | 2 + internal/generator/command.go | 14 + internal/generator/flagConsts.go | 9 + internal/generator/generator.go | 12 + pkg/config/generatorOption.go | 130 +++++++ pkg/generator/feeds.go | 100 +++++ pkg/generator/feeds_test.go | 413 +++++++++++++++++++++ pkg/generator/generatedSite.go | 25 +- pkg/generator/generator.go | 137 +++++-- pkg/models/baseData.go | 13 + pkg/outputter/directoryWriter.go | 50 ++- pkg/templates/default/partials/head.tmpl | 6 + pkg/templates/default/partials/header.tmpl | 3 + 17 files changed, 885 insertions(+), 42 deletions(-) create mode 100644 pkg/generator/feeds.go create mode 100644 pkg/generator/feeds_test.go diff --git a/README.md b/README.md index 1a10b4a..b28c5d2 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ goblog serve posts/ | `--disable-reading-time` | | `false` | Disable reading time estimation on posts | | `--root-path` | `-p` | `/` | Blog root path for subdirectory deployment | | `--template-dir` | `-t` | built-in | Path to a custom template directory | +| `--base-url` | | _(none)_ | Absolute base URL of the site (e.g. `https://example.com`); required to generate RSS/Atom feeds | +| `--disable-feeds` | | `false` | Disable RSS and Atom feed generation | +| `--feed-limit` | | `10` | Maximum number of posts to include in each feed | ### `serve` flags diff --git a/go.mod b/go.mod index 29dd1ac..e0741b3 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/alecthomas/chroma/v2 v2.22.0 github.com/fatih/color v1.18.0 github.com/fsnotify/fsnotify v1.10.1 + github.com/gorilla/feeds v1.2.0 github.com/harrydayexe/GoWebUtilities v1.5.1 github.com/urfave/cli/v3 v3.6.1 github.com/yuin/goldmark v1.7.16 diff --git a/go.sum b/go.sum index d71e508..ba83e7e 100644 --- a/go.sum +++ b/go.sum @@ -21,10 +21,16 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= +github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= github.com/harrydayexe/GoWebUtilities v1.5.1 h1:1E/5IEBHdG8SfIwF2tqJhxypsk9VH1TIb1ofMmI6ZOg= github.com/harrydayexe/GoWebUtilities v1.5.1/go.mod h1:msGwhqkUbSAhhIR+uFnLE08OhBCwuJW0BQ5kUnBvI6I= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -32,6 +38,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/integration/go.mod b/integration/go.mod index 3b8268a..c6bebc6 100644 --- a/integration/go.mod +++ b/integration/go.mod @@ -32,6 +32,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/feeds v1.2.0 // indirect github.com/harrydayexe/GoWebUtilities v1.5.1 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect diff --git a/integration/go.sum b/integration/go.sum index e85f39f..8ba350a 100644 --- a/integration/go.sum +++ b/integration/go.sum @@ -63,6 +63,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= +github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= github.com/harrydayexe/GoWebUtilities v1.5.1 h1:1E/5IEBHdG8SfIwF2tqJhxypsk9VH1TIb1ofMmI6ZOg= github.com/harrydayexe/GoWebUtilities v1.5.1/go.mod h1:msGwhqkUbSAhhIR+uFnLE08OhBCwuJW0BQ5kUnBvI6I= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= diff --git a/internal/generator/command.go b/internal/generator/command.go index 3cb3211..0dd2cc2 100644 --- a/internal/generator/command.go +++ b/internal/generator/command.go @@ -56,5 +56,19 @@ var GeneratorCommand cli.Command = cli.Command{ Usage: "disable reading time estimation on posts", Value: false, }, + &cli.StringFlag{ + Name: BaseURLFlagName, + Usage: "absolute base URL of the site (e.g. https://example.com); required for RSS/Atom feed generation", + }, + &cli.BoolFlag{ + Name: DisableFeedsFlagName, + Usage: "disable RSS and Atom feed generation", + Value: false, + }, + &cli.IntFlag{ + Name: FeedLimitFlagName, + Usage: "maximum number of posts to include in each feed (default 10)", + Value: 0, + }, }, } diff --git a/internal/generator/flagConsts.go b/internal/generator/flagConsts.go index 29582a5..9c52778 100644 --- a/internal/generator/flagConsts.go +++ b/internal/generator/flagConsts.go @@ -24,3 +24,12 @@ const DisableTagsFlagName = "disable-tags" // DisableReadingTimeFlagName is the CLI flag name for disabling reading time estimation. const DisableReadingTimeFlagName = "disable-reading-time" + +// BaseURLFlagName is the CLI flag name for setting the site's absolute base URL (required for feed generation). +const BaseURLFlagName = "base-url" + +// DisableFeedsFlagName is the CLI flag name for disabling RSS and Atom feed generation. +const DisableFeedsFlagName = "disable-feeds" + +// FeedLimitFlagName is the CLI flag name for setting the maximum number of posts in each feed. +const FeedLimitFlagName = "feed-limit" diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 310e0d7..e85c72b 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -59,6 +59,18 @@ func NewGeneratorCommand(ctx context.Context, c *cli.Command) error { opts = append(opts, config.WithDisableReadingTime()) } + if baseURL := c.String(BaseURLFlagName); baseURL != "" { + opts = append(opts, config.WithBaseURL(baseURL)) + } + + if c.Bool(DisableFeedsFlagName) { + opts = append(opts, config.WithDisableFeeds()) + } + + if feedLimit := c.Int(FeedLimitFlagName); feedLimit > 0 { + opts = append(opts, config.WithFeedPostLimit(feedLimit)) + } + templateDirPath := c.String(TemplateDirFlagName) var templateDir fs.FS if templateDirPath == "" { diff --git a/pkg/config/generatorOption.go b/pkg/config/generatorOption.go index c7c0bd8..bace0a0 100644 --- a/pkg/config/generatorOption.go +++ b/pkg/config/generatorOption.go @@ -14,6 +14,7 @@ package config // This type should not be constructed directly by users. Instead, use the // provided option functions like WithRawOutput(), WithDisableTags(), // WithDisableReadingTime(), WithSiteTitle(), WithEnvironment(), WithCustomData(), +// WithBaseURL(), WithDisableFeeds(), WithFeedPostLimit(), // or call [BaseOption.AsGeneratorOption] on a [BaseOption] value. type GeneratorOption struct { BaseOption @@ -25,6 +26,9 @@ type GeneratorOption struct { WithEnvironmentFunc func(v *Environment) WithCustomDataFunc func(v *CustomData) WithHTMLPathsFunc func(v *HTMLPaths) + WithBaseURLFunc func(v *BaseURL) + WithDisableFeedsFunc func(v *DisableFeeds) + WithFeedPostLimitFunc func(v *FeedPostLimit) } // WithBaseOption wraps a BaseOption as a GeneratorOption so it can be passed @@ -350,3 +354,129 @@ func WithCustomData(data map[string]any) GeneratorOption { func (o CustomData) AsOption() GeneratorOption { return WithCustomData(o.Data) } + +// BaseURL is a configuration type that holds the absolute base URL of the site +// (e.g. "https://example.com" or "https://example.com/blog"). +// +// BaseURL is required for feed generation: RSS and Atom items must reference +// fully-qualified URLs. When BaseURL is empty, the generator skips feed +// generation and emits an info log. HTML page generation is not affected. +// +// This type is typically embedded in generator configuration structs and should +// be set using the [WithBaseURL] option function. +type BaseURL string + +// WithBaseURL returns a GeneratorOption that sets the site's absolute base URL. +// +// The base URL is prepended to site-relative paths to produce fully-qualified +// URLs for RSS/Atom feed items. It must include the scheme and host +// (e.g. "https://example.com") and may include a path prefix that matches +// [BlogRoot] when the blog is not at the domain root. +// +// Trailing slashes are trimmed before the base URL is used, so +// "https://example.com/" and "https://example.com" are equivalent. +// +// When no base URL is configured, feed generation is silently skipped +// (see [WithDisableFeeds] to explicitly opt out instead). +// +// Example usage: +// +// gen := generator.New(fsys, renderer, config.WithBaseURL("https://example.com")) +func WithBaseURL(url string) GeneratorOption { + return GeneratorOption{ + WithBaseURLFunc: func(v *BaseURL) { + *v = BaseURL(url) + }, + } +} + +// AsOption returns a GeneratorOption that re-applies this BaseURL value to +// another component. +func (o BaseURL) AsOption() GeneratorOption { + return WithBaseURL(string(o)) +} + +// DisableFeeds is a configuration type that controls whether RSS and Atom +// feeds are generated. +// +// When Disable is true: +// - The generator skips all feed generation even if a BaseURL is configured +// - GeneratedBlog feed fields are left empty +// - The outputter writes no feed files +// - BaseData.FeedsEnabled is set to false for all templates +// +// This type is typically embedded in generator and outputter configuration +// structs and should be set using the [WithDisableFeeds] option function. +type DisableFeeds struct{ Disable bool } + +// WithDisableFeeds returns a GeneratorOption that disables all feed generation. +// +// By default, GoBlog generates RSS 2.0 and Atom feeds when a base URL is +// configured via [WithBaseURL]. Applying this option suppresses that behaviour +// entirely, regardless of whether a base URL is set. +// +// When applied to the outputter, no feed files are written. +// Templates receive BaseData.FeedsEnabled = false and will suppress any +// feed discovery links and visible RSS navigation. +// +// Example usage: +// +// gen := generator.New(fsys, renderer, config.WithDisableFeeds()) +// writer := outputter.NewDirectoryWriter("output/", config.WithDisableFeeds()) +func WithDisableFeeds() GeneratorOption { + return GeneratorOption{ + WithDisableFeedsFunc: func(v *DisableFeeds) { + v.Disable = true + }, + } +} + +// AsOption converts this DisableFeeds value back into a GeneratorOption. +func (o DisableFeeds) AsOption() GeneratorOption { + if o.Disable { + return WithDisableFeeds() + } + return GeneratorOption{ + WithDisableFeedsFunc: func(v *DisableFeeds) { + v.Disable = false + }, + } +} + +// FeedPostLimit is a configuration type that controls the maximum number of +// posts included in each generated feed. +// +// The limit applies to both the site-wide feed and every per-tag feed. +// Posts are selected in reverse-chronological order (newest first), so a +// limit of 10 means only the 10 most recent posts appear in each feed. +// +// The zero value (unset) is treated as 10 by the generator. +// +// This type is typically embedded in generator configuration structs and +// should be set using the [WithFeedPostLimit] option function. +type FeedPostLimit struct{ Limit int } + +// WithFeedPostLimit returns a GeneratorOption that sets the maximum number of +// posts included in each generated feed. +// +// When not set, the generator defaults to 10 posts per feed. +// The same limit applies to the site-wide feed and every per-tag feed. +// +// Example usage: +// +// gen := generator.New(fsys, renderer, +// config.WithBaseURL("https://example.com"), +// config.WithFeedPostLimit(20), +// ) +func WithFeedPostLimit(limit int) GeneratorOption { + return GeneratorOption{ + WithFeedPostLimitFunc: func(v *FeedPostLimit) { + v.Limit = limit + }, + } +} + +// AsOption converts this FeedPostLimit value back into a GeneratorOption. +func (o FeedPostLimit) AsOption() GeneratorOption { + return WithFeedPostLimit(o.Limit) +} diff --git a/pkg/generator/feeds.go b/pkg/generator/feeds.go new file mode 100644 index 0000000..17472bf --- /dev/null +++ b/pkg/generator/feeds.go @@ -0,0 +1,100 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package generator + +import ( + "fmt" + "strings" + + "github.com/gorilla/feeds" + "github.com/harrydayexe/GoBlog/v2/pkg/models" +) + +// buildFeeds generates RSS 2.0 and Atom XML bytes for the given list of posts. +// +// posts must already be sorted (newest first) and will be sliced to limit +// entries before building the feed. absPostURL is a function that accepts a +// post slug and returns the fully-qualified URL for that post's page. +// +// The feed's own link (the href used in channel/feed metadata) is built from +// siteURL, which is the value of config.BaseURL with the trailing slash trimmed. +// +// Both the RSS and Atom representations are returned; if the list is empty +// both slices are nil. +func buildFeeds( + posts models.PostList, + limit int, + siteTitle string, + siteURL string, + absPostURL func(slug string) string, +) (rss []byte, atom []byte, err error) { + if len(posts) == 0 { + return nil, nil, nil + } + + // Trim to the configured limit. + if limit > 0 && len(posts) > limit { + posts = posts[:limit] + } + + // The feed's updated time is the date of the newest post. + updated := posts[0].Date + + feed := &feeds.Feed{ + Title: siteTitle, + Link: &feeds.Link{Href: siteURL}, + // Description is required by RSS 2.0. + Description: siteTitle + " — RSS Feed", + Created: updated, + Updated: updated, + } + + feed.Items = make([]*feeds.Item, 0, len(posts)) + for _, post := range posts { + postURL := absPostURL(post.Slug) + + // Use LastEdited as the updated time when set; otherwise fall back to Date. + itemUpdated := post.Date + if !post.LastEdited.IsZero() { + itemUpdated = post.LastEdited + } + + item := &feeds.Item{ + // The post URL serves as the unique, permanent identifier. + Id: postURL, + Title: post.Title, + Link: &feeds.Link{Href: postURL}, + Description: post.Description, + // Full HTML content (not a summary). + Content: string(post.Content), + Created: post.Date, + Updated: itemUpdated, + } + + if post.Author != "" { + item.Author = &feeds.Author{Name: post.Author} + } + + feed.Items = append(feed.Items, item) + } + + rssStr, err := feed.ToRss() + if err != nil { + return nil, nil, fmt.Errorf("generating RSS feed: %w", err) + } + + atomStr, err := feed.ToAtom() + if err != nil { + return nil, nil, fmt.Errorf("generating Atom feed: %w", err) + } + + return []byte(rssStr), []byte(atomStr), nil +} + +// absURL returns the absolute URL for a site-relative path by prepending the +// base URL (trailing slash trimmed). +func absURL(baseURL, path string) string { + return strings.TrimRight(baseURL, "/") + path +} diff --git a/pkg/generator/feeds_test.go b/pkg/generator/feeds_test.go new file mode 100644 index 0000000..e9e3fb2 --- /dev/null +++ b/pkg/generator/feeds_test.go @@ -0,0 +1,413 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package generator + +import ( + "context" + "encoding/xml" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/harrydayexe/GoBlog/v2/pkg/config" + "github.com/harrydayexe/GoBlog/v2/pkg/models" + "github.com/harrydayexe/GoBlog/v2/pkg/templates" +) + +// ---- helpers ---------------------------------------------------------------- + +func makePosts(n int) models.PostList { + posts := make(models.PostList, n) + for i := range n { + posts[i] = &models.Post{ + Title: strings.Repeat("Post ", i+1), + Date: time.Date(2024, time.January, n-i, 0, 0, 0, 0, time.UTC), + Description: "desc", + Slug: strings.ToLower(strings.ReplaceAll(strings.TrimSpace(strings.Repeat("post ", i+1)), " ", "-")), + Content: []byte("

content " + strings.Repeat("post ", i+1) + "

"), + } + } + return posts +} + +func absPostURLFn(base string) func(slug string) string { + return func(slug string) string { + return base + "/posts/" + slug + } +} + +// ---- buildFeeds tests ------------------------------------------------------- + +func TestBuildFeeds_EmptyPosts(t *testing.T) { + t.Parallel() + + rss, atom, err := buildFeeds(nil, 10, "Blog", "https://example.com", absPostURLFn("https://example.com")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rss != nil || atom != nil { + t.Errorf("expected nil output for empty posts, got rss=%d bytes atom=%d bytes", len(rss), len(atom)) + } +} + +func TestBuildFeeds_ProducesValidXML(t *testing.T) { + t.Parallel() + + posts := makePosts(3) + rss, atom, err := buildFeeds(posts, 10, "Test Blog", "https://example.com", absPostURLFn("https://example.com")) + if err != nil { + t.Fatalf("buildFeeds error: %v", err) + } + + if err := xml.Unmarshal(rss, new(interface{})); err != nil { + t.Errorf("RSS output is not valid XML: %v", err) + } + if err := xml.Unmarshal(atom, new(interface{})); err != nil { + t.Errorf("Atom output is not valid XML: %v", err) + } +} + +func TestBuildFeeds_AbsoluteURLsInItems(t *testing.T) { + t.Parallel() + + posts := makePosts(2) + rss, atom, err := buildFeeds(posts, 10, "My Blog", "https://myblog.com", absPostURLFn("https://myblog.com")) + if err != nil { + t.Fatalf("buildFeeds error: %v", err) + } + + for _, feed := range []struct { + name string + content []byte + }{ + {"RSS", rss}, + {"Atom", atom}, + } { + for _, post := range posts { + wantURL := "https://myblog.com/posts/" + post.Slug + if !strings.Contains(string(feed.content), wantURL) { + t.Errorf("%s feed missing absolute URL %q", feed.name, wantURL) + } + } + } +} + +func TestBuildFeeds_LimitRespected(t *testing.T) { + t.Parallel() + + posts := makePosts(15) + rss, _, err := buildFeeds(posts, 5, "Blog", "https://example.com", absPostURLFn("https://example.com")) + if err != nil { + t.Fatalf("buildFeeds error: %v", err) + } + + // Only the first 5 posts (newest) should appear. Posts 6-15 must not. + for i, post := range posts { + url := "https://example.com/posts/" + post.Slug + if i < 5 { + if !strings.Contains(string(rss), url) { + t.Errorf("post %d should be in feed but is missing", i+1) + } + } else { + if strings.Contains(string(rss), url) { + t.Errorf("post %d should be excluded by limit but is present", i+1) + } + } + } +} + +func TestBuildFeeds_FullContentIncluded(t *testing.T) { + t.Parallel() + + posts := models.PostList{ + { + Title: "Hello", + Slug: "hello", + Date: time.Now(), + Content: []byte("

full HTML content here

"), + }, + } + + rss, atom, err := buildFeeds(posts, 10, "Blog", "https://example.com", absPostURLFn("https://example.com")) + if err != nil { + t.Fatalf("buildFeeds error: %v", err) + } + + for _, feed := range []struct { + name string + content []byte + }{ + {"RSS", rss}, + {"Atom", atom}, + } { + if !strings.Contains(string(feed.content), "full HTML content here") { + t.Errorf("%s feed does not contain full post content", feed.name) + } + } +} + +func TestBuildFeeds_PostURLIsItemID(t *testing.T) { + t.Parallel() + + posts := models.PostList{ + {Title: "A Post", Slug: "a-post", Date: time.Now(), Content: []byte("x")}, + } + rss, atom, err := buildFeeds(posts, 10, "Blog", "https://example.com", absPostURLFn("https://example.com")) + if err != nil { + t.Fatalf("buildFeeds error: %v", err) + } + + wantURL := "https://example.com/posts/a-post" + // RSS uses ; Atom uses . Both must contain the full post URL. + if !strings.Contains(string(rss), ""+wantURL+"") { + t.Errorf("RSS feed guid does not equal post URL; feed:\n%s", rss) + } + if !strings.Contains(string(atom), ""+wantURL+"") { + t.Errorf("Atom feed id does not equal post URL; feed:\n%s", atom) + } +} + +// ---- absURL tests ----------------------------------------------------------- + +func TestAbsURL(t *testing.T) { + t.Parallel() + + tests := []struct { + base string + path string + want string + }{ + {"https://example.com", "/posts/hello", "https://example.com/posts/hello"}, + {"https://example.com/", "/posts/hello", "https://example.com/posts/hello"}, + {"https://example.com/blog", "/posts/hello", "https://example.com/blog/posts/hello"}, + {"https://example.com/blog/", "/posts/hello", "https://example.com/blog/posts/hello"}, + } + + for _, tt := range tests { + t.Run(tt.base+tt.path, func(t *testing.T) { + t.Parallel() + got := absURL(tt.base, tt.path) + if got != tt.want { + t.Errorf("absURL(%q, %q) = %q, want %q", tt.base, tt.path, got, tt.want) + } + }) + } +} + +// ---- generator integration tests ------------------------------------------- + +func newTestRenderer(t *testing.T) *TemplateRenderer { + t.Helper() + r, err := NewTemplateRenderer(templates.Default) + if err != nil { + t.Fatalf("NewTemplateRenderer: %v", err) + } + return r +} + +func newTestPostsFS(t *testing.T, posts map[string]string) fstest.MapFS { + t.Helper() + fs := fstest.MapFS{} + for name, content := range posts { + fs[name] = &fstest.MapFile{Data: []byte(content)} + } + return fs +} + +const simplePost = `--- +title: "Hello World" +date: 2024-06-01 +description: "A simple post" +author: "Alice" +--- +# Hello + +This is content. +` + +const olderPost = `--- +title: "Old Post" +date: 2024-01-01 +description: "An older post" +--- +Old content. +` + +func TestGenerator_FeedsSkippedWhenNoBaseURL(t *testing.T) { + t.Parallel() + + postsFS := newTestPostsFS(t, map[string]string{"hello.md": simplePost}) + gen := New(postsFS, newTestRenderer(t)) + + blog, err := gen.Generate(context.Background()) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + if blog.RSSFeed != nil || blog.AtomFeed != nil { + t.Errorf("expected no feeds when base URL is unset, got rss=%d atom=%d", len(blog.RSSFeed), len(blog.AtomFeed)) + } +} + +func TestGenerator_FeedsGeneratedWithBaseURL(t *testing.T) { + t.Parallel() + + postsFS := newTestPostsFS(t, map[string]string{"hello.md": simplePost}) + gen := New(postsFS, newTestRenderer(t), config.WithBaseURL("https://example.com")) + + blog, err := gen.Generate(context.Background()) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + if len(blog.RSSFeed) == 0 { + t.Error("expected RSS feed, got empty bytes") + } + if len(blog.AtomFeed) == 0 { + t.Error("expected Atom feed, got empty bytes") + } + + // Both should be valid XML. + if err := xml.Unmarshal(blog.RSSFeed, new(interface{})); err != nil { + t.Errorf("RSS output is not valid XML: %v", err) + } + if err := xml.Unmarshal(blog.AtomFeed, new(interface{})); err != nil { + t.Errorf("Atom output is not valid XML: %v", err) + } + + // Post URLs must be absolute. + if !strings.Contains(string(blog.RSSFeed), "https://example.com/posts/hello-world") { + t.Errorf("RSS feed missing absolute post URL; feed:\n%s", blog.RSSFeed) + } +} + +func TestGenerator_FeedsDisabled(t *testing.T) { + t.Parallel() + + postsFS := newTestPostsFS(t, map[string]string{"hello.md": simplePost}) + gen := New(postsFS, newTestRenderer(t), + config.WithBaseURL("https://example.com"), + config.WithDisableFeeds(), + ) + + blog, err := gen.Generate(context.Background()) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + if blog.RSSFeed != nil || blog.AtomFeed != nil { + t.Errorf("expected no feeds when WithDisableFeeds is set") + } +} + +func TestGenerator_FeedPostLimit(t *testing.T) { + t.Parallel() + + // Create 5 posts but limit the feed to 2. + const postTemplate = `--- +title: "Post %d" +date: 2024-0%d-01 +description: "Post number %d" +--- +Content %d. +` + postsMap := map[string]string{} + for i := 1; i <= 5; i++ { + name := "post" + strings.Repeat("0", 1) + string(rune('0'+i)) + ".md" + postsMap[name] = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll( + postTemplate, "%d", "%v"), "%v", strings.Repeat("%v", 1)), "%v", "x"), "x", "1") + } + + // Simpler: use fstest directly with known slugs. + fs := fstest.MapFS{ + "post1.md": {Data: []byte("---\ntitle: \"Post 1\"\ndate: 2024-05-01\ndescription: \"p1\"\n---\ncontent 1")}, + "post2.md": {Data: []byte("---\ntitle: \"Post 2\"\ndate: 2024-04-01\ndescription: \"p2\"\n---\ncontent 2")}, + "post3.md": {Data: []byte("---\ntitle: \"Post 3\"\ndate: 2024-03-01\ndescription: \"p3\"\n---\ncontent 3")}, + "post4.md": {Data: []byte("---\ntitle: \"Post 4\"\ndate: 2024-02-01\ndescription: \"p4\"\n---\ncontent 4")}, + "post5.md": {Data: []byte("---\ntitle: \"Post 5\"\ndate: 2024-01-01\ndescription: \"p5\"\n---\ncontent 5")}, + } + + gen := New(fs, newTestRenderer(t), + config.WithBaseURL("https://example.com"), + config.WithFeedPostLimit(2), + ) + + blog, err := gen.Generate(context.Background()) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + // Only the 2 newest posts (post1 = May, post2 = April) should appear. + rss := string(blog.RSSFeed) + if !strings.Contains(rss, "https://example.com/posts/post-1") { + t.Error("post 1 (newest) should be in feed") + } + if !strings.Contains(rss, "https://example.com/posts/post-2") { + t.Error("post 2 should be in feed") + } + if strings.Contains(rss, "https://example.com/posts/post-3") { + t.Error("post 3 should be excluded by limit") + } +} + +func TestGenerator_PerTagFeeds(t *testing.T) { + t.Parallel() + + postsFS := fstest.MapFS{ + "go-post.md": {Data: []byte("---\ntitle: \"Go Post\"\ndate: 2024-06-01\ndescription: \"d\"\ntags:\n - go\n---\nContent.")}, + "js-post.md": {Data: []byte("---\ntitle: \"JS Post\"\ndate: 2024-05-01\ndescription: \"d\"\ntags:\n - javascript\n---\nContent.")}, + } + + gen := New(postsFS, newTestRenderer(t), config.WithBaseURL("https://example.com")) + + blog, err := gen.Generate(context.Background()) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + if len(blog.TagRSSFeeds) == 0 { + t.Error("expected per-tag RSS feeds, got none") + } + + goFeed, ok := blog.TagRSSFeeds["go"] + if !ok { + t.Fatal("expected 'go' tag feed") + } + if !strings.Contains(string(goFeed), "https://example.com/posts/go-post") { + t.Errorf("'go' tag feed missing go post; feed:\n%s", goFeed) + } + if strings.Contains(string(goFeed), "https://example.com/posts/js-post") { + t.Errorf("'go' tag feed should not contain js post; feed:\n%s", goFeed) + } +} + +func TestGenerator_FeedsEnabledInBaseData(t *testing.T) { + t.Parallel() + + // Without base URL: FeedsEnabled must be false in templates. + postsFS := newTestPostsFS(t, map[string]string{"hello.md": simplePost}) + genNoURL := New(postsFS, newTestRenderer(t)) + + blog, err := genNoURL.Generate(context.Background()) + if err != nil { + t.Fatalf("Generate (no URL): %v", err) + } + // The rendered HTML must not contain the RSS link rel. + if strings.Contains(string(blog.Index), "application/rss+xml") { + t.Error("index page should not have RSS link when no base URL is set") + } + + // With base URL: FeedsEnabled must be true in templates. + genWithURL := New(postsFS, newTestRenderer(t), config.WithBaseURL("https://example.com")) + + blog2, err := genWithURL.Generate(context.Background()) + if err != nil { + t.Fatalf("Generate (with URL): %v", err) + } + if !strings.Contains(string(blog2.Index), "application/rss+xml") { + t.Error("index page should have RSS discovery link when base URL is set") + } +} diff --git a/pkg/generator/generatedSite.go b/pkg/generator/generatedSite.go index 69e03d6..eef423f 100644 --- a/pkg/generator/generatedSite.go +++ b/pkg/generator/generatedSite.go @@ -46,16 +46,37 @@ package generator // templates guard the "· N min read" annotation with // {{if .Post.ReadingTimeMinutes}}, so the annotation is simply omitted without // any other changes to the output structure. +// +// # Feed Mode +// +// When the generator is configured with config.WithBaseURL() (and feeds have +// not been disabled via config.WithDisableFeeds()), the following feed fields +// are populated: +// - RSSFeed / AtomFeed: site-wide RSS 2.0 / Atom XML bytes +// - TagRSSFeeds / TagAtomFeeds: per-tag RSS 2.0 / Atom XML, keyed by tag name +// +// Feeds are empty (nil) when: +// - No base URL is configured (feeds are silently skipped) +// - config.WithDisableFeeds() was applied +// - config.WithRawOutput() was applied (raw mode skips all template rendering) +// - config.WithDisableTags() was applied (tag feeds are additionally empty in this mode) type GeneratedBlog struct { Posts map[string][]byte // Posts maps a slug to raw HTML bytes for each post Index []byte // Index contains the raw HTML for the blog index page Tags map[string][]byte // Tags maps each tag name to its tag page HTML TagsIndex []byte // TagsIndex contains the raw HTML for the tags index page + + RSSFeed []byte // RSSFeed contains the site-wide RSS 2.0 feed XML, or nil if feeds are disabled/skipped + AtomFeed []byte // AtomFeed contains the site-wide Atom feed XML, or nil if feeds are disabled/skipped + TagRSSFeeds map[string][]byte // TagRSSFeeds maps each tag name to its RSS 2.0 feed XML + TagAtomFeeds map[string][]byte // TagAtomFeeds maps each tag name to its Atom feed XML } func NewEmptyGeneratedBlog() *GeneratedBlog { return &GeneratedBlog{ - Posts: make(map[string][]byte), - Tags: make(map[string][]byte), + Posts: make(map[string][]byte), + Tags: make(map[string][]byte), + TagRSSFeeds: make(map[string][]byte), + TagAtomFeeds: make(map[string][]byte), } } diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go index 3f9b3e9..5d8116b 100644 --- a/pkg/generator/generator.go +++ b/pkg/generator/generator.go @@ -36,6 +36,9 @@ type Generator struct { config.CustomData config.HTMLPaths config.Logger + config.BaseURL + config.DisableFeeds + config.FeedPostLimit ParserConfig parser.Config // The config to use when parsing renderer *TemplateRenderer @@ -50,7 +53,10 @@ func (c Generator) String() string { - BlogRoot %s, - Environment %s, - CustomData keys %d, -- HTMLPaths %t`, +- HTMLPaths %t, +- BaseURL %s, +- DisableFeeds %t, +- FeedPostLimit %d`, c.RawOutput, c.DisableTags.Disable, c.DisableReadingTime.Disable, @@ -59,6 +65,9 @@ func (c Generator) String() string { c.Environment.Environment, len(c.CustomData.Data), c.HTMLPaths.Enable, + c.BaseURL, + c.DisableFeeds.Disable, + c.FeedPostLimit.Limit, ) } @@ -68,7 +77,8 @@ func (c Generator) String() string { // // Optional config.GeneratorOption values control behavior: config.WithRawOutput, // config.WithDisableTags, config.WithDisableReadingTime, config.WithSiteTitle, -// config.WithBlogRoot, config.WithEnvironment, config.WithCustomData. +// config.WithBlogRoot, config.WithEnvironment, config.WithCustomData, +// config.WithBaseURL, config.WithDisableFeeds, config.WithFeedPostLimit. // The template renderer is supplied as a positional argument, not an option. func New(posts fs.FS, renderer *TemplateRenderer, opts ...config.GeneratorOption) *Generator { gen := Generator{ @@ -95,6 +105,12 @@ func New(posts fs.FS, renderer *TemplateRenderer, opts ...config.GeneratorOption opt.WithCustomDataFunc(&gen.CustomData) } else if opt.WithHTMLPathsFunc != nil { opt.WithHTMLPathsFunc(&gen.HTMLPaths) + } else if opt.WithBaseURLFunc != nil { + opt.WithBaseURLFunc(&gen.BaseURL) + } else if opt.WithDisableFeedsFunc != nil { + opt.WithDisableFeedsFunc(&gen.DisableFeeds) + } else if opt.WithFeedPostLimitFunc != nil { + opt.WithFeedPostLimitFunc(&gen.FeedPostLimit) } else if opt.WithLoggerFunc != nil { opt.WithLoggerFunc(&gen.Logger) } @@ -114,6 +130,10 @@ func New(posts fs.FS, renderer *TemplateRenderer, opts ...config.GeneratorOption gen.Environment = config.Environment{Environment: "local"} } + if gen.FeedPostLimit.Limit == 0 { + gen.FeedPostLimit = config.FeedPostLimit{Limit: 10} + } + return &gen } @@ -234,6 +254,12 @@ func (g *Generator) assembleBlogWithTemplates(ctx context.Context, posts models. tagsEnabled := !g.DisableTags.Disable + // feeds are active when not explicitly disabled AND a base URL is configured. + feedsEnabled := !g.DisableFeeds.Disable && g.BaseURL != "" + if !g.DisableFeeds.Disable && g.BaseURL == "" { + g.Logger.Logger.InfoContext(ctx, "feeds enabled but no base URL configured; skipping feed generation (use WithBaseURL to enable feeds)") + } + // When tags are disabled, clear Post.Tags so template tag pills do not render. if !tagsEnabled { for _, post := range posts { @@ -251,19 +277,34 @@ func (g *Generator) assembleBlogWithTemplates(ctx context.Context, posts models. // Sort posts by date descending posts.SortByDate() + // Build site-wide feeds from the already-sorted post list. + if feedsEnabled { + siteURL := absURL(string(g.BaseURL), string(g.BlogRoot)) + absPostURL := func(slug string) string { + return absURL(string(g.BaseURL), string(g.BlogRoot)+"posts/"+slug) + } + rss, atom, err := buildFeeds(posts, g.FeedPostLimit.Limit, g.SiteTitle.SiteTitle, siteURL, absPostURL) + if err != nil { + return nil, fmt.Errorf("building site-wide feeds: %w", err) + } + blog.RSSFeed = rss + blog.AtomFeed = atom + } + // Render individual post pages for _, post := range posts { data := models.PostPageData{ BaseData: models.BaseData{ - SiteTitle: g.SiteTitle.SiteTitle, - PageTitle: post.Title, - Description: post.Description, - Year: time.Now().Year(), - BlogRoot: string(g.BlogRoot), - Environment: g.Environment.Environment, - TagsEnabled: tagsEnabled, - Custom: g.CustomData.Data, - Path: g.pagePath("post", post.Slug), + SiteTitle: g.SiteTitle.SiteTitle, + PageTitle: post.Title, + Description: post.Description, + Year: time.Now().Year(), + BlogRoot: string(g.BlogRoot), + Environment: g.Environment.Environment, + TagsEnabled: tagsEnabled, + FeedsEnabled: feedsEnabled, + Custom: g.CustomData.Data, + Path: g.pagePath("post", post.Slug), }, Post: post, } @@ -286,15 +327,16 @@ func (g *Generator) assembleBlogWithTemplates(ctx context.Context, posts models. // Render index page indexData := models.IndexPageData{ BaseData: models.BaseData{ - SiteTitle: g.SiteTitle.SiteTitle, - PageTitle: "Home", - Description: "Recent blog posts", - Year: time.Now().Year(), - BlogRoot: string(g.BlogRoot), - Environment: g.Environment.Environment, - TagsEnabled: tagsEnabled, - Custom: g.CustomData.Data, - Path: g.pagePath("index", ""), + SiteTitle: g.SiteTitle.SiteTitle, + PageTitle: "Home", + Description: "Recent blog posts", + Year: time.Now().Year(), + BlogRoot: string(g.BlogRoot), + Environment: g.Environment.Environment, + TagsEnabled: tagsEnabled, + FeedsEnabled: feedsEnabled, + Custom: g.CustomData.Data, + Path: g.pagePath("index", ""), }, Posts: indexPosts, TotalPosts: len(indexPosts), @@ -319,15 +361,16 @@ func (g *Generator) assembleBlogWithTemplates(ctx context.Context, posts models. tagData := models.TagPageData{ BaseData: models.BaseData{ - SiteTitle: g.SiteTitle.SiteTitle, - PageTitle: "Tag: " + tag, - Description: fmt.Sprintf("Posts tagged with %s", tag), - Year: time.Now().Year(), - BlogRoot: string(g.BlogRoot), - Environment: g.Environment.Environment, - TagsEnabled: true, - Custom: g.CustomData.Data, - Path: g.pagePath("tag", tag), + SiteTitle: g.SiteTitle.SiteTitle, + PageTitle: "Tag: " + tag, + Description: fmt.Sprintf("Posts tagged with %s", tag), + Year: time.Now().Year(), + BlogRoot: string(g.BlogRoot), + Environment: g.Environment.Environment, + TagsEnabled: true, + FeedsEnabled: feedsEnabled, + Custom: g.CustomData.Data, + Path: g.pagePath("tag", tag), }, Tag: tag, Posts: tagPosts, @@ -340,6 +383,23 @@ func (g *Generator) assembleBlogWithTemplates(ctx context.Context, posts models. } blog.Tags[tag] = rendered + + // Build per-tag feeds alongside the tag page. + if feedsEnabled { + tagSiteURL := absURL(string(g.BaseURL), string(g.BlogRoot)) + absPostURL := func(slug string) string { + return absURL(string(g.BaseURL), string(g.BlogRoot)+"posts/"+slug) + } + tagTitle := g.SiteTitle.SiteTitle + " — " + tag + tagRSS, tagAtom, err := buildFeeds(tagPosts, g.FeedPostLimit.Limit, tagTitle, tagSiteURL, absPostURL) + if err != nil { + return nil, fmt.Errorf("building feeds for tag %q: %w", tag, err) + } + if tagRSS != nil { + blog.TagRSSFeeds[tag] = tagRSS + blog.TagAtomFeeds[tag] = tagAtom + } + } } // Render tags index page @@ -359,15 +419,16 @@ func (g *Generator) assembleBlogWithTemplates(ctx context.Context, posts models. tagsIndexData := models.TagsIndexPageData{ BaseData: models.BaseData{ - SiteTitle: g.SiteTitle.SiteTitle, - PageTitle: "All Tags", - Description: "Browse all topics covered in this blog", - Year: time.Now().Year(), - BlogRoot: string(g.BlogRoot), - Environment: g.Environment.Environment, - TagsEnabled: true, - Custom: g.CustomData.Data, - Path: g.pagePath("tagsIndex", ""), + SiteTitle: g.SiteTitle.SiteTitle, + PageTitle: "All Tags", + Description: "Browse all topics covered in this blog", + Year: time.Now().Year(), + BlogRoot: string(g.BlogRoot), + Environment: g.Environment.Environment, + TagsEnabled: true, + FeedsEnabled: feedsEnabled, + Custom: g.CustomData.Data, + Path: g.pagePath("tagsIndex", ""), }, Tags: tagInfos, TotalTags: len(tagInfos), diff --git a/pkg/models/baseData.go b/pkg/models/baseData.go index df56c84..68ee06a 100644 --- a/pkg/models/baseData.go +++ b/pkg/models/baseData.go @@ -42,6 +42,19 @@ type BaseData struct { // {{if .TagsEnabled}}Tags{{end}} TagsEnabled bool + // FeedsEnabled indicates whether RSS and Atom feeds are available for this blog. + // When true, the default templates render feed discovery tags in the + // and a visible RSS navigation link. Custom templates should also gate + // feed UI on this field. + // + // The Go zero value is false. The Generator sets this to true when both a + // base URL is configured (via config.WithBaseURL) and feeds have not been + // disabled (via config.WithDisableFeeds). + // + // Custom templates should gate feed UI on this field: + // {{if .FeedsEnabled}}RSS{{end}} + FeedsEnabled bool + // Custom holds arbitrary key-value data injected by the calling application // via config.WithCustomData. It is nil when no custom data was configured. // diff --git a/pkg/outputter/directoryWriter.go b/pkg/outputter/directoryWriter.go index ea4962f..fb71f0b 100644 --- a/pkg/outputter/directoryWriter.go +++ b/pkg/outputter/directoryWriter.go @@ -79,6 +79,10 @@ func NewDirectoryWriter(outputDir string, opts ...config.GeneratorOption) Direct // - posts/{slug}.html: individual post files, one per post // - tags/{tag}.html: tag pages (only if RawOutput and DisableTags are false) // - tags/index.html: tags index page (only if RawOutput and DisableTags are false) +// - rss.xml: site-wide RSS 2.0 feed (only when the generator produced one) +// - atom.xml: site-wide Atom feed (only when the generator produced one) +// - tags/{tag}.rss.xml: per-tag RSS 2.0 feed (only when the generator produced them) +// - tags/{tag}.atom.xml: per-tag Atom feed (only when the generator produced them) // // When RawOutput mode is enabled (via config.WithRawOutput()), the tags/ // directory is not created and individual post files contain only raw HTML @@ -90,6 +94,11 @@ func NewDirectoryWriter(outputDir string, opts ...config.GeneratorOption) Direct // directory is not created. Posts and the index page are still written with // full templates. // +// Feed files are written only when the generator has populated them (i.e. when +// config.WithBaseURL was set and config.WithDisableFeeds was not applied). The +// outputter does not need to know about feed configuration — it simply writes +// whatever the generator produced. +// // All necessary directories are created automatically with permissions 0755. // Files are written with permissions 0644. // @@ -129,6 +138,30 @@ func (dw DirectoryWriter) HandleGeneratedBlog(ctx context.Context, blog *generat } } + // Write site-wide feed files when the generator produced them. + if len(blog.RSSFeed) > 0 { + if err := os.WriteFile(filepath.Join(dw.outputDir, "rss.xml"), blog.RSSFeed, 0644); err != nil { + return err + } + } + if len(blog.AtomFeed) > 0 { + if err := os.WriteFile(filepath.Join(dw.outputDir, "atom.xml"), blog.AtomFeed, 0644); err != nil { + return err + } + } + + // Write per-tag feed files when the generator produced them. + // The tags directory is guaranteed to exist at this point if tag feeds were generated + // (tags are required for tag feeds). We still guard with MkdirAll for safety. + if len(blog.TagRSSFeeds) > 0 || len(blog.TagAtomFeeds) > 0 { + if err := writeMapToFilesExt(blog.TagRSSFeeds, filepath.Join(dw.outputDir, "tags"), ".rss.xml"); err != nil { + return err + } + if err := writeMapToFilesExt(blog.TagAtomFeeds, filepath.Join(dw.outputDir, "tags"), ".atom.xml"); err != nil { + return err + } + } + dw.Logger.Logger.InfoContext(ctx, "Finished writing to output directory") return nil } @@ -143,13 +176,26 @@ func (dw DirectoryWriter) HandleGeneratedBlog(ctx context.Context, blog *generat // // Returns an error if directory creation or any file write fails. func writeMapToFiles(data map[string][]byte, outputDir string) error { + return writeMapToFilesExt(data, outputDir, ".html") +} + +// writeMapToFilesExt writes a map of filename->content pairs to disk, appending +// the given extension to each key to form the on-disk filename. +// +// For example, with ext=".rss.xml" and key "golang", the file is written as +// "golang.rss.xml" inside outputDir. +// +// The outputDir is created if it doesn't exist, with permissions 0755. +// Files are written with permissions 0644. +// +// Returns an error if directory creation or any file write fails. +func writeMapToFilesExt(data map[string][]byte, outputDir string, ext string) error { if err := os.MkdirAll(outputDir, 0755); err != nil { return err } for filename, content := range data { - htmlFile := filename + ".html" - path := filepath.Join(outputDir, htmlFile) + path := filepath.Join(outputDir, filename+ext) if err := os.WriteFile(path, content, 0644); err != nil { return err } diff --git a/pkg/templates/default/partials/head.tmpl b/pkg/templates/default/partials/head.tmpl index a87f983..061d2d4 100644 --- a/pkg/templates/default/partials/head.tmpl +++ b/pkg/templates/default/partials/head.tmpl @@ -11,6 +11,12 @@ + + {{if .FeedsEnabled}} + + + {{end}} + diff --git a/pkg/templates/default/partials/header.tmpl b/pkg/templates/default/partials/header.tmpl index 5f94c71..b66e599 100644 --- a/pkg/templates/default/partials/header.tmpl +++ b/pkg/templates/default/partials/header.tmpl @@ -17,6 +17,9 @@ {{if .TagsEnabled}} Tags {{end}} + {{if .FeedsEnabled}} + RSS + {{end}} From 49dd6c2c735000c45d4570d43bcfa92c1ed73960 Mon Sep 17 00:00:00 2001 From: Harry Day Date: Sat, 20 Jun 2026 19:38:25 +0100 Subject: [PATCH 2/7] refactor(generator): make feed building private methods on Generator Feed logic now lives as unexported methods on *Generator, pulling limit, title, base URL, and blog root directly from the receiver rather than requiring callers to pass them explicitly. AbsURL remains exported as a standalone utility. Call sites in assembleBlogWithTemplates simplify to g.buildFeeds(posts) and g.buildFeedsWithTitle(posts, title). --- pkg/generator/feeds.go | 50 ++++++++++++++++++++++--------------- pkg/generator/feeds_test.go | 49 +++++++++++++++++++++++------------- pkg/generator/generator.go | 13 ++-------- 3 files changed, 63 insertions(+), 49 deletions(-) diff --git a/pkg/generator/feeds.go b/pkg/generator/feeds.go index 17472bf..3ad30be 100644 --- a/pkg/generator/feeds.go +++ b/pkg/generator/feeds.go @@ -12,41 +12,51 @@ import ( "github.com/harrydayexe/GoBlog/v2/pkg/models" ) -// buildFeeds generates RSS 2.0 and Atom XML bytes for the given list of posts. +// buildFeeds generates RSS 2.0 and Atom XML bytes for the given list of posts +// using the generator's configured site title, base URL, blog root, and post +// limit. // -// posts must already be sorted (newest first) and will be sliced to limit -// entries before building the feed. absPostURL is a function that accepts a -// post slug and returns the fully-qualified URL for that post's page. +// posts must already be sorted (newest first); they are sliced to the +// generator's FeedPostLimit before the feed is assembled. // -// The feed's own link (the href used in channel/feed metadata) is built from -// siteURL, which is the value of config.BaseURL with the trailing slash trimmed. +// Both the RSS 2.0 and Atom representations are returned. If the list is +// empty both slices are nil. // -// Both the RSS and Atom representations are returned; if the list is empty -// both slices are nil. -func buildFeeds( - posts models.PostList, - limit int, - siteTitle string, - siteURL string, - absPostURL func(slug string) string, -) (rss []byte, atom []byte, err error) { +// The post URL is derived from the generator's BaseURL and BlogRoot as: +// +// posts/ +// +// and is used as both the item link and its unique identifier (guid/id). +// Each item carries the post's full rendered HTML content. +func (g *Generator) buildFeeds(posts models.PostList) (rss []byte, atom []byte, err error) { + return g.buildFeedsWithTitle(posts, g.SiteTitle.SiteTitle) +} + +// buildFeedsWithTitle is the internal implementation shared by BuildFeeds and +// per-tag feed generation, where the feed title differs from the site title. +func (g *Generator) buildFeedsWithTitle(posts models.PostList, feedTitle string) (rss []byte, atom []byte, err error) { if len(posts) == 0 { return nil, nil, nil } - // Trim to the configured limit. + limit := g.FeedPostLimit.Limit if limit > 0 && len(posts) > limit { posts = posts[:limit] } + siteURL := AbsURL(string(g.BaseURL), string(g.BlogRoot)) + absPostURL := func(slug string) string { + return AbsURL(string(g.BaseURL), string(g.BlogRoot)+"posts/"+slug) + } + // The feed's updated time is the date of the newest post. updated := posts[0].Date feed := &feeds.Feed{ - Title: siteTitle, + Title: feedTitle, Link: &feeds.Link{Href: siteURL}, // Description is required by RSS 2.0. - Description: siteTitle + " — RSS Feed", + Description: feedTitle + " — RSS Feed", Created: updated, Updated: updated, } @@ -93,8 +103,8 @@ func buildFeeds( return []byte(rssStr), []byte(atomStr), nil } -// absURL returns the absolute URL for a site-relative path by prepending the +// AbsURL returns the absolute URL for a site-relative path by prepending the // base URL (trailing slash trimmed). -func absURL(baseURL, path string) string { +func AbsURL(baseURL, path string) string { return strings.TrimRight(baseURL, "/") + path } diff --git a/pkg/generator/feeds_test.go b/pkg/generator/feeds_test.go index e9e3fb2..01b2401 100644 --- a/pkg/generator/feeds_test.go +++ b/pkg/generator/feeds_test.go @@ -33,18 +33,26 @@ func makePosts(n int) models.PostList { return posts } -func absPostURLFn(base string) func(slug string) string { - return func(slug string) string { - return base + "/posts/" + slug +// newFeedsGen constructs a minimal Generator for BuildFeeds tests, +// using the provided base URL, site title, and post limit. +func newFeedsGen(baseURL, siteTitle string, limit int) *Generator { + opts := []config.GeneratorOption{ + config.WithBaseURL(baseURL), + config.WithSiteTitle(siteTitle), } + if limit > 0 { + opts = append(opts, config.WithFeedPostLimit(limit)) + } + return New(fstest.MapFS{}, nil, opts...) } -// ---- buildFeeds tests ------------------------------------------------------- +// ---- BuildFeeds tests ------------------------------------------------------- func TestBuildFeeds_EmptyPosts(t *testing.T) { t.Parallel() - rss, atom, err := buildFeeds(nil, 10, "Blog", "https://example.com", absPostURLFn("https://example.com")) + gen := newFeedsGen("https://example.com", "Blog", 10) + rss, atom, err := gen.buildFeeds(nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -56,10 +64,11 @@ func TestBuildFeeds_EmptyPosts(t *testing.T) { func TestBuildFeeds_ProducesValidXML(t *testing.T) { t.Parallel() + gen := newFeedsGen("https://example.com", "Test Blog", 10) posts := makePosts(3) - rss, atom, err := buildFeeds(posts, 10, "Test Blog", "https://example.com", absPostURLFn("https://example.com")) + rss, atom, err := gen.buildFeeds(posts) if err != nil { - t.Fatalf("buildFeeds error: %v", err) + t.Fatalf("BuildFeeds error: %v", err) } if err := xml.Unmarshal(rss, new(interface{})); err != nil { @@ -73,10 +82,11 @@ func TestBuildFeeds_ProducesValidXML(t *testing.T) { func TestBuildFeeds_AbsoluteURLsInItems(t *testing.T) { t.Parallel() + gen := newFeedsGen("https://myblog.com", "My Blog", 10) posts := makePosts(2) - rss, atom, err := buildFeeds(posts, 10, "My Blog", "https://myblog.com", absPostURLFn("https://myblog.com")) + rss, atom, err := gen.buildFeeds(posts) if err != nil { - t.Fatalf("buildFeeds error: %v", err) + t.Fatalf("BuildFeeds error: %v", err) } for _, feed := range []struct { @@ -98,10 +108,11 @@ func TestBuildFeeds_AbsoluteURLsInItems(t *testing.T) { func TestBuildFeeds_LimitRespected(t *testing.T) { t.Parallel() + gen := newFeedsGen("https://example.com", "Blog", 5) posts := makePosts(15) - rss, _, err := buildFeeds(posts, 5, "Blog", "https://example.com", absPostURLFn("https://example.com")) + rss, _, err := gen.buildFeeds(posts) if err != nil { - t.Fatalf("buildFeeds error: %v", err) + t.Fatalf("BuildFeeds error: %v", err) } // Only the first 5 posts (newest) should appear. Posts 6-15 must not. @@ -122,6 +133,7 @@ func TestBuildFeeds_LimitRespected(t *testing.T) { func TestBuildFeeds_FullContentIncluded(t *testing.T) { t.Parallel() + gen := newFeedsGen("https://example.com", "Blog", 10) posts := models.PostList{ { Title: "Hello", @@ -131,9 +143,9 @@ func TestBuildFeeds_FullContentIncluded(t *testing.T) { }, } - rss, atom, err := buildFeeds(posts, 10, "Blog", "https://example.com", absPostURLFn("https://example.com")) + rss, atom, err := gen.buildFeeds(posts) if err != nil { - t.Fatalf("buildFeeds error: %v", err) + t.Fatalf("BuildFeeds error: %v", err) } for _, feed := range []struct { @@ -152,12 +164,13 @@ func TestBuildFeeds_FullContentIncluded(t *testing.T) { func TestBuildFeeds_PostURLIsItemID(t *testing.T) { t.Parallel() + gen := newFeedsGen("https://example.com", "Blog", 10) posts := models.PostList{ {Title: "A Post", Slug: "a-post", Date: time.Now(), Content: []byte("x")}, } - rss, atom, err := buildFeeds(posts, 10, "Blog", "https://example.com", absPostURLFn("https://example.com")) + rss, atom, err := gen.buildFeeds(posts) if err != nil { - t.Fatalf("buildFeeds error: %v", err) + t.Fatalf("BuildFeeds error: %v", err) } wantURL := "https://example.com/posts/a-post" @@ -170,7 +183,7 @@ func TestBuildFeeds_PostURLIsItemID(t *testing.T) { } } -// ---- absURL tests ----------------------------------------------------------- +// ---- AbsURL tests ----------------------------------------------------------- func TestAbsURL(t *testing.T) { t.Parallel() @@ -189,9 +202,9 @@ func TestAbsURL(t *testing.T) { for _, tt := range tests { t.Run(tt.base+tt.path, func(t *testing.T) { t.Parallel() - got := absURL(tt.base, tt.path) + got := AbsURL(tt.base, tt.path) if got != tt.want { - t.Errorf("absURL(%q, %q) = %q, want %q", tt.base, tt.path, got, tt.want) + t.Errorf("AbsURL(%q, %q) = %q, want %q", tt.base, tt.path, got, tt.want) } }) } diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go index 5d8116b..bb2d1d9 100644 --- a/pkg/generator/generator.go +++ b/pkg/generator/generator.go @@ -279,11 +279,7 @@ func (g *Generator) assembleBlogWithTemplates(ctx context.Context, posts models. // Build site-wide feeds from the already-sorted post list. if feedsEnabled { - siteURL := absURL(string(g.BaseURL), string(g.BlogRoot)) - absPostURL := func(slug string) string { - return absURL(string(g.BaseURL), string(g.BlogRoot)+"posts/"+slug) - } - rss, atom, err := buildFeeds(posts, g.FeedPostLimit.Limit, g.SiteTitle.SiteTitle, siteURL, absPostURL) + rss, atom, err := g.buildFeeds(posts) if err != nil { return nil, fmt.Errorf("building site-wide feeds: %w", err) } @@ -386,12 +382,7 @@ func (g *Generator) assembleBlogWithTemplates(ctx context.Context, posts models. // Build per-tag feeds alongside the tag page. if feedsEnabled { - tagSiteURL := absURL(string(g.BaseURL), string(g.BlogRoot)) - absPostURL := func(slug string) string { - return absURL(string(g.BaseURL), string(g.BlogRoot)+"posts/"+slug) - } - tagTitle := g.SiteTitle.SiteTitle + " — " + tag - tagRSS, tagAtom, err := buildFeeds(tagPosts, g.FeedPostLimit.Limit, tagTitle, tagSiteURL, absPostURL) + tagRSS, tagAtom, err := g.buildFeedsWithTitle(tagPosts, g.SiteTitle.SiteTitle+" — "+tag) if err != nil { return nil, fmt.Errorf("building feeds for tag %q: %w", tag, err) } From 5c22efc628b8e09612440bca8097d23b29273700 Mon Sep 17 00:00:00 2001 From: Harry Day Date: Sat, 20 Jun 2026 19:45:44 +0100 Subject: [PATCH 3/7] refactor(generator): eliminate redundant title parameter from feed methods Replace buildFeedsWithTitle(posts, title) with two intent-named methods: buildFeeds for the site-wide feed and buildTagFeeds(tag, posts) for per-tag feeds. Both derive the feed title from the generator's own SiteTitle, removing a parameter that duplicated information already available on the receiver. --- pkg/generator/feeds.go | 49 ++++++++++++++++++-------------------- pkg/generator/generator.go | 2 +- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/pkg/generator/feeds.go b/pkg/generator/feeds.go index 3ad30be..82fbaa8 100644 --- a/pkg/generator/feeds.go +++ b/pkg/generator/feeds.go @@ -12,29 +12,28 @@ import ( "github.com/harrydayexe/GoBlog/v2/pkg/models" ) -// buildFeeds generates RSS 2.0 and Atom XML bytes for the given list of posts -// using the generator's configured site title, base URL, blog root, and post +// buildFeeds generates the site-wide RSS 2.0 and Atom feeds from the given +// post list, using the generator's site title, base URL, blog root, and post // limit. // -// posts must already be sorted (newest first); they are sliced to the -// generator's FeedPostLimit before the feed is assembled. -// -// Both the RSS 2.0 and Atom representations are returned. If the list is -// empty both slices are nil. -// -// The post URL is derived from the generator's BaseURL and BlogRoot as: -// -// posts/ -// -// and is used as both the item link and its unique identifier (guid/id). -// Each item carries the post's full rendered HTML content. +// posts must already be sorted newest-first. Both representations are +// returned; if posts is empty both slices are nil. func (g *Generator) buildFeeds(posts models.PostList) (rss []byte, atom []byte, err error) { - return g.buildFeedsWithTitle(posts, g.SiteTitle.SiteTitle) + return g.buildFeedsForTitle(posts, g.SiteTitle.SiteTitle) +} + +// buildTagFeeds generates per-tag RSS 2.0 and Atom feeds for the given post +// list, titling the feed "". +// +// posts must already be sorted newest-first and pre-filtered to the tag. +// Both representations are returned; if posts is empty both slices are nil. +func (g *Generator) buildTagFeeds(tag string, posts models.PostList) (rss []byte, atom []byte, err error) { + return g.buildFeedsForTitle(posts, g.SiteTitle.SiteTitle+" — "+tag) } -// buildFeedsWithTitle is the internal implementation shared by BuildFeeds and -// per-tag feed generation, where the feed title differs from the site title. -func (g *Generator) buildFeedsWithTitle(posts models.PostList, feedTitle string) (rss []byte, atom []byte, err error) { +// buildFeedsForTitle is the shared implementation for buildFeeds and +// buildTagFeeds. title is the channel/feed title to embed in the output. +func (g *Generator) buildFeedsForTitle(posts models.PostList, title string) (rss []byte, atom []byte, err error) { if len(posts) == 0 { return nil, nil, nil } @@ -53,10 +52,9 @@ func (g *Generator) buildFeedsWithTitle(posts models.PostList, feedTitle string) updated := posts[0].Date feed := &feeds.Feed{ - Title: feedTitle, - Link: &feeds.Link{Href: siteURL}, - // Description is required by RSS 2.0. - Description: feedTitle + " — RSS Feed", + Title: title, + Link: &feeds.Link{Href: siteURL}, + Description: title + " — RSS Feed", Created: updated, Updated: updated, } @@ -77,10 +75,9 @@ func (g *Generator) buildFeedsWithTitle(posts models.PostList, feedTitle string) Title: post.Title, Link: &feeds.Link{Href: postURL}, Description: post.Description, - // Full HTML content (not a summary). - Content: string(post.Content), - Created: post.Date, - Updated: itemUpdated, + Content: string(post.Content), + Created: post.Date, + Updated: itemUpdated, } if post.Author != "" { diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go index bb2d1d9..257d715 100644 --- a/pkg/generator/generator.go +++ b/pkg/generator/generator.go @@ -382,7 +382,7 @@ func (g *Generator) assembleBlogWithTemplates(ctx context.Context, posts models. // Build per-tag feeds alongside the tag page. if feedsEnabled { - tagRSS, tagAtom, err := g.buildFeedsWithTitle(tagPosts, g.SiteTitle.SiteTitle+" — "+tag) + tagRSS, tagAtom, err := g.buildTagFeeds(tag, tagPosts) if err != nil { return nil, fmt.Errorf("building feeds for tag %q: %w", tag, err) } From 25521ca502046d5a461739d6f7aa14fe42ca2d13 Mon Sep 17 00:00:00 2001 From: Harry Day Date: Sat, 20 Jun 2026 20:02:29 +0100 Subject: [PATCH 4/7] fix(generator): set feed to max effective-updated across posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feed-level (Atom) and (RSS) were always set to posts[0].Date — the newest post's publication date. Per RFC 4287 §4.2.15 the feed's must reflect the most recent instant any entry was significantly modified. Editing an older post without publishing a new one left the timestamp unchanged, so aggregators and caches could miss the edit entirely. Fix computes the maximum effective-updated time across all windowed posts (LastEdited if set, otherwise Date) and uses that for feed.Updated; feed.Created remains posts[0].Date. Per-item timestamps were already correct and are unchanged. Adds three tests for the previously zero-coverage LastEdited paths: feed-level updated reflects an edit, item Atom reflects LastEdited, and regression guard when no edits are present. --- pkg/generator/feeds.go | 16 +++++- pkg/generator/feeds_test.go | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/pkg/generator/feeds.go b/pkg/generator/feeds.go index 82fbaa8..9023c88 100644 --- a/pkg/generator/feeds.go +++ b/pkg/generator/feeds.go @@ -48,14 +48,26 @@ func (g *Generator) buildFeedsForTitle(posts models.PostList, title string) (rss return AbsURL(string(g.BaseURL), string(g.BlogRoot)+"posts/"+slug) } - // The feed's updated time is the date of the newest post. + // The feed's updated time is the most recent effective-updated time across + // all included posts, per RFC 4287: a feed's must reflect the + // most recent instant any entry was significantly modified. Using only + // posts[0].Date would miss edits to older posts. updated := posts[0].Date + for _, p := range posts { + eff := p.Date + if !p.LastEdited.IsZero() { + eff = p.LastEdited + } + if eff.After(updated) { + updated = eff + } + } feed := &feeds.Feed{ Title: title, Link: &feeds.Link{Href: siteURL}, Description: title + " — RSS Feed", - Created: updated, + Created: posts[0].Date, Updated: updated, } diff --git a/pkg/generator/feeds_test.go b/pkg/generator/feeds_test.go index 01b2401..2a1e048 100644 --- a/pkg/generator/feeds_test.go +++ b/pkg/generator/feeds_test.go @@ -183,6 +183,117 @@ func TestBuildFeeds_PostURLIsItemID(t *testing.T) { } } +// ---- LastEdited / edited-post tests ---------------------------------------- + +// TestBuildFeeds_FeedUpdatedReflectsEdit asserts that the Atom feed-level +// is the most recent LastEdited across all included posts, not just +// the newest post's publication Date. Per RFC 4287 §4.2.15 the feed +// must reflect the most recent modification to any included entry. +func TestBuildFeeds_FeedUpdatedReflectsEdit(t *testing.T) { + t.Parallel() + + newestPublished := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + editDate := time.Date(2024, time.July, 1, 0, 0, 0, 0, time.UTC) // after newestPublished + + posts := models.PostList{ + // Newest by Date — no edit. + {Title: "Newest", Slug: "newest", Date: newestPublished, Description: "d", Content: []byte("x")}, + // Older post, but edited after newestPublished. + {Title: "Older but edited", Slug: "older-but-edited", + Date: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC), + LastEdited: editDate, + Description: "d", Content: []byte("x")}, + } + + gen := newFeedsGen("https://example.com", "Blog", 0) + _, atom, err := gen.buildFeeds(posts) + if err != nil { + t.Fatalf("buildFeeds error: %v", err) + } + + wantUpdated := editDate.Format(time.RFC3339) + if !strings.Contains(string(atom), wantUpdated) { + t.Errorf("Atom feed should contain edit date %s; feed:\n%s", wantUpdated, atom) + } + + // The old (pre-fix) value must not appear as the feed updated when a newer + // edit exists. + oldUpdated := newestPublished.Format(time.RFC3339) + // The newestPublished timestamp may appear in the entry's , so + // we check feed.Updated specifically by asserting the edit timestamp comes + // first in the feed (it always precedes the entry block in Atom). + atomStr := string(atom) + editIdx := strings.Index(atomStr, wantUpdated) + oldIdx := strings.Index(atomStr, oldUpdated) + if editIdx == -1 { + t.Errorf("edit date %s not found in Atom feed:\n%s", wantUpdated, atom) + } + if oldIdx != -1 && oldIdx < editIdx { + t.Errorf("publication date %s appears before edit date %s; expected edit date to be the feed-level ; feed:\n%s", + oldUpdated, wantUpdated, atom) + } +} + +// TestBuildFeeds_ItemAtomTimestampsForEditedPost asserts that an edited post's +// Atom entry reflects LastEdited, not the original publication Date. +// Note: gorilla/feeds does not emit from Item.Created at the +// high-level Feed API, so only is checked here. +func TestBuildFeeds_ItemAtomTimestampsForEditedPost(t *testing.T) { + t.Parallel() + + pubDate := time.Date(2024, time.March, 1, 0, 0, 0, 0, time.UTC) + editDate := time.Date(2024, time.May, 1, 0, 0, 0, 0, time.UTC) + + posts := models.PostList{ + {Title: "Edited Post", Slug: "edited-post", + Date: pubDate, + LastEdited: editDate, + Description: "d", Content: []byte("x")}, + } + + gen := newFeedsGen("https://example.com", "Blog", 0) + _, atom, err := gen.buildFeeds(posts) + if err != nil { + t.Fatalf("buildFeeds error: %v", err) + } + + atomStr := string(atom) + wantUpdated := editDate.Format(time.RFC3339) + wantNotUpdated := pubDate.Format(time.RFC3339) + + if !strings.Contains(atomStr, ""+wantUpdated+"") { + t.Errorf("Atom entry missing %s; feed:\n%s", wantUpdated, atom) + } + // The publication date must not appear as the item's timestamp. + if strings.Contains(atomStr, ""+wantNotUpdated+"") { + t.Errorf("Atom entry should be LastEdited %s, not publication date %s; feed:\n%s", + wantUpdated, wantNotUpdated, atom) + } +} + +// TestBuildFeeds_FeedUpdatedNoEdits asserts that when no post has LastEdited +// the feed-level updated remains the newest post's Date (regression guard). +func TestBuildFeeds_FeedUpdatedNoEdits(t *testing.T) { + t.Parallel() + + newestDate := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + posts := models.PostList{ + {Title: "Newest", Slug: "newest", Date: newestDate, Description: "d", Content: []byte("x")}, + {Title: "Older", Slug: "older", Date: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC), Description: "d", Content: []byte("x")}, + } + + gen := newFeedsGen("https://example.com", "Blog", 0) + _, atom, err := gen.buildFeeds(posts) + if err != nil { + t.Fatalf("buildFeeds error: %v", err) + } + + wantUpdated := newestDate.Format(time.RFC3339) + if !strings.Contains(string(atom), wantUpdated) { + t.Errorf("Atom feed should contain newest date %s when no edits present; feed:\n%s", wantUpdated, atom) + } +} + // ---- AbsURL tests ----------------------------------------------------------- func TestAbsURL(t *testing.T) { From 817d5a29d364ac2d938d0c217fca2ec8cb8caff3 Mon Sep 17 00:00:00 2001 From: Harry Day Date: Sat, 20 Jun 2026 20:12:10 +0100 Subject: [PATCH 5/7] refactor(generator): unexport absURL, fix feed-limit CLI default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename AbsURL → absURL; it has no external consumers and is an internal helper for feed URL construction. - Set --feed-limit flag default to 10 (matching the generator's internal default) so the CLI help text displays the correct value rather than 0. - Remove dead code in TestGenerator_FeedPostLimit: an abandoned postTemplate const and postsMap loop that were superseded by an inline fstest.MapFS but never deleted. --- internal/generator/command.go | 4 ++-- pkg/generator/feeds.go | 8 ++++---- pkg/generator/feeds_test.go | 17 +---------------- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/internal/generator/command.go b/internal/generator/command.go index 0dd2cc2..bdfb893 100644 --- a/internal/generator/command.go +++ b/internal/generator/command.go @@ -67,8 +67,8 @@ var GeneratorCommand cli.Command = cli.Command{ }, &cli.IntFlag{ Name: FeedLimitFlagName, - Usage: "maximum number of posts to include in each feed (default 10)", - Value: 0, + Usage: "maximum number of posts to include in each feed", + Value: 10, }, }, } diff --git a/pkg/generator/feeds.go b/pkg/generator/feeds.go index 9023c88..48e19f6 100644 --- a/pkg/generator/feeds.go +++ b/pkg/generator/feeds.go @@ -43,9 +43,9 @@ func (g *Generator) buildFeedsForTitle(posts models.PostList, title string) (rss posts = posts[:limit] } - siteURL := AbsURL(string(g.BaseURL), string(g.BlogRoot)) + siteURL := absURL(string(g.BaseURL), string(g.BlogRoot)) absPostURL := func(slug string) string { - return AbsURL(string(g.BaseURL), string(g.BlogRoot)+"posts/"+slug) + return absURL(string(g.BaseURL), string(g.BlogRoot)+"posts/"+slug) } // The feed's updated time is the most recent effective-updated time across @@ -112,8 +112,8 @@ func (g *Generator) buildFeedsForTitle(posts models.PostList, title string) (rss return []byte(rssStr), []byte(atomStr), nil } -// AbsURL returns the absolute URL for a site-relative path by prepending the +// absURL returns the absolute URL for a site-relative path by prepending the // base URL (trailing slash trimmed). -func AbsURL(baseURL, path string) string { +func absURL(baseURL, path string) string { return strings.TrimRight(baseURL, "/") + path } diff --git a/pkg/generator/feeds_test.go b/pkg/generator/feeds_test.go index 2a1e048..0934e79 100644 --- a/pkg/generator/feeds_test.go +++ b/pkg/generator/feeds_test.go @@ -313,7 +313,7 @@ func TestAbsURL(t *testing.T) { for _, tt := range tests { t.Run(tt.base+tt.path, func(t *testing.T) { t.Parallel() - got := AbsURL(tt.base, tt.path) + got := absURL(tt.base, tt.path) if got != tt.want { t.Errorf("AbsURL(%q, %q) = %q, want %q", tt.base, tt.path, got, tt.want) } @@ -431,21 +431,6 @@ func TestGenerator_FeedPostLimit(t *testing.T) { t.Parallel() // Create 5 posts but limit the feed to 2. - const postTemplate = `--- -title: "Post %d" -date: 2024-0%d-01 -description: "Post number %d" ---- -Content %d. -` - postsMap := map[string]string{} - for i := 1; i <= 5; i++ { - name := "post" + strings.Repeat("0", 1) + string(rune('0'+i)) + ".md" - postsMap[name] = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll( - postTemplate, "%d", "%v"), "%v", strings.Repeat("%v", 1)), "%v", "x"), "x", "1") - } - - // Simpler: use fstest directly with known slugs. fs := fstest.MapFS{ "post1.md": {Data: []byte("---\ntitle: \"Post 1\"\ndate: 2024-05-01\ndescription: \"p1\"\n---\ncontent 1")}, "post2.md": {Data: []byte("---\ntitle: \"Post 2\"\ndate: 2024-04-01\ndescription: \"p2\"\n---\ncontent 2")}, From c915a5dde12f180ef31f4f13237c170b8a101755 Mon Sep 17 00:00:00 2001 From: Harry Day Date: Sat, 20 Jun 2026 20:47:59 +0100 Subject: [PATCH 6/7] feat(cli): hoist shared flags to root command and add feed routes to serve Move template-dir, root-path, disable-tags, disable-reading-time, base-url, disable-feeds, and feed-limit from per-subcommand flag definitions to the top-level goblog command via a new internal/cliflags package. urfave/cli v3 persists root flags to all subcommands, so both generate and serve share one canonical definition with no duplication. Wire the three feed options (base-url, disable-feeds, feed-limit) into the serve command's generator config so the embedded generator produces RSS/Atom content. Extend pkg/server/handler to expose that content at {root}rss.xml, {root}atom.xml, and {root}tags/{tag}.{rss,atom}.xml, matching the static file layout written by pkg/outputter. Feed routes return 404 when no base-url is configured. --- README.md | 21 +-- cmd/goblog/main.go | 5 +- internal/cliflags/flags.go | 80 ++++++++++++ internal/generator/command.go | 36 ------ internal/generator/flagConsts.go | 21 --- internal/generator/generator.go | 15 ++- internal/server/command.go | 22 ---- internal/server/command_test.go | 54 ++++++++ internal/server/flagConsts.go | 12 -- internal/server/server.go | 22 +++- pkg/server/doc.go | 14 ++ pkg/server/feed_test.go | 214 +++++++++++++++++++++++++++++++ pkg/server/handler.go | 75 ++++++++++- 13 files changed, 478 insertions(+), 113 deletions(-) create mode 100644 internal/cliflags/flags.go create mode 100644 pkg/server/feed_test.go diff --git a/README.md b/README.md index b28c5d2..44bbfd1 100644 --- a/README.md +++ b/README.md @@ -24,29 +24,34 @@ goblog generate posts/ output/ goblog serve posts/ ``` -### `generate` flags +### Global flags + +These flags apply to both `generate` and `serve` and may be passed before or after the subcommand name. | Flag | Short | Default | Description | |---|---|---|---| -| `--raw` | `-r` | `false` | Output raw HTML without template wrapping | +| `--template-dir` | `-t` | built-in | Path to a custom template directory | +| `--root-path` | `-p` | `/` | Blog root path for subdirectory deployment | | `--disable-tags` | `-T` | `false` | Disable tag tracking and tag page generation | | `--disable-reading-time` | | `false` | Disable reading time estimation on posts | -| `--root-path` | `-p` | `/` | Blog root path for subdirectory deployment | -| `--template-dir` | `-t` | built-in | Path to a custom template directory | | `--base-url` | | _(none)_ | Absolute base URL of the site (e.g. `https://example.com`); required to generate RSS/Atom feeds | | `--disable-feeds` | | `false` | Disable RSS and Atom feed generation | | `--feed-limit` | | `10` | Maximum number of posts to include in each feed | +### `generate` flags + +| Flag | Short | Default | Description | +|---|---|---|---| +| `--raw` | `-r` | `false` | Output raw HTML without template wrapping | + ### `serve` flags +When `--base-url` is set, the server also exposes the generated feeds at `{root-path}rss.xml`, `{root-path}atom.xml`, and per-tag feeds at `{root-path}tags/{tag}.rss.xml` / `{root-path}tags/{tag}.atom.xml`. + | Flag | Short | Default | Description | |---|---|---|---| | `--port` | `-P` | `8080` | TCP port to listen on | | `--host` | `-H` | all interfaces | Host address to bind to | -| `--disable-tags` | `-T` | `false` | Disable tag tracking and tag page generation | -| `--disable-reading-time` | | `false` | Disable reading time estimation on posts | -| `--root-path` | `-p` | `/` | Blog root path for subdirectory deployment | -| `--template-dir` | `-t` | built-in | Path to a custom template directory | | `--watch` | `-w` | `false` | Watch the posts directory and regenerate on changes | | `--cache-control` | | `1h` | Max-age TTL for the `Cache-Control` header (`0` disables) | | `--health-checks` | | `false` | Expose `/healthz/live`, `/healthz/ready`, and `/healthz/startup` endpoints (no auth required); server binds before loading content so probes observe startup state | diff --git a/cmd/goblog/main.go b/cmd/goblog/main.go index c3a6f59..fccd3e9 100644 --- a/cmd/goblog/main.go +++ b/cmd/goblog/main.go @@ -10,6 +10,7 @@ import ( "log/slog" "os" + "github.com/harrydayexe/GoBlog/v2/internal/cliflags" "github.com/harrydayexe/GoBlog/v2/internal/generator" loggermod "github.com/harrydayexe/GoBlog/v2/internal/logger" "github.com/harrydayexe/GoBlog/v2/internal/server" @@ -45,7 +46,7 @@ func main() { &generator.GeneratorCommand, &server.ServeCommand, }, - Flags: []cli.Flag{ + Flags: append([]cli.Flag{ &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, @@ -54,7 +55,7 @@ func main() { Count: &verbosity, }, }, - }, + }, cliflags.Shared()...), Before: func(ctx context.Context, c *cli.Command) (context.Context, error) { var level slog.Level switch verbosity { diff --git a/internal/cliflags/flags.go b/internal/cliflags/flags.go new file mode 100644 index 0000000..5f1417c --- /dev/null +++ b/internal/cliflags/flags.go @@ -0,0 +1,80 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package cliflags defines the CLI flag names and flag definitions that are +// shared between the generate and serve subcommands. Keeping them here avoids +// duplication and import cycles: both the root goblog command (which registers +// the flags on the top-level cli.Command) and the subcommand action packages +// (which read flag values) can import this package. +package cliflags + +import "github.com/urfave/cli/v3" + +// TemplateDirFlagName is the CLI flag name for setting a custom template directory. +const TemplateDirFlagName = "template-dir" + +// BlogRootFlagName is the CLI flag name for setting the blog root path. +const BlogRootFlagName = "root-path" + +// DisableTagsFlagName is the CLI flag name for disabling tag page generation. +const DisableTagsFlagName = "disable-tags" + +// DisableReadingTimeFlagName is the CLI flag name for disabling reading time estimation. +const DisableReadingTimeFlagName = "disable-reading-time" + +// BaseURLFlagName is the CLI flag name for setting the site's absolute base URL +// (required for RSS/Atom feed generation). +const BaseURLFlagName = "base-url" + +// DisableFeedsFlagName is the CLI flag name for disabling RSS and Atom feed generation. +const DisableFeedsFlagName = "disable-feeds" + +// FeedLimitFlagName is the CLI flag name for setting the maximum number of posts +// in each feed. +const FeedLimitFlagName = "feed-limit" + +// Shared returns the CLI flag definitions that apply to both the generate and +// serve subcommands. They are registered on the top-level goblog command so that +// urfave/cli v3's default persistence makes them available in both subcommand +// actions via c.String/Bool/Int. +func Shared() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: TemplateDirFlagName, + Aliases: []string{"t"}, + Usage: "directory of templates to use when rendering", + }, + &cli.StringFlag{ + Name: BlogRootFlagName, + Aliases: []string{"p"}, + Usage: "root path of the blog, defaults to '/'", + Value: "/", + }, + &cli.BoolFlag{ + Name: DisableTagsFlagName, + Aliases: []string{"T"}, + Usage: "disable tag tracking and tag page generation", + Value: false, + }, + &cli.BoolFlag{ + Name: DisableReadingTimeFlagName, + Usage: "disable reading time estimation on posts", + Value: false, + }, + &cli.StringFlag{ + Name: BaseURLFlagName, + Usage: "absolute base URL of the site (e.g. https://example.com); required for RSS/Atom feed generation", + }, + &cli.BoolFlag{ + Name: DisableFeedsFlagName, + Usage: "disable RSS and Atom feed generation", + Value: false, + }, + &cli.IntFlag{ + Name: FeedLimitFlagName, + Usage: "maximum number of posts to include in each feed", + Value: 10, + }, + } +} diff --git a/internal/generator/command.go b/internal/generator/command.go index bdfb893..06467fc 100644 --- a/internal/generator/command.go +++ b/internal/generator/command.go @@ -34,41 +34,5 @@ var GeneratorCommand cli.Command = cli.Command{ Usage: "output raw HTML without template wrapper (skips tag pages and template rendering)", Value: false, }, - &cli.StringFlag{ - Name: TemplateDirFlagName, - Aliases: []string{"t"}, - Usage: "directory of templates to use when rendering", - }, - &cli.StringFlag{ - Name: BlogRootFlagName, - Aliases: []string{"p"}, - Usage: "root path of the blog, defaults to '/'", - Value: "/", - }, - &cli.BoolFlag{ - Name: DisableTagsFlagName, - Aliases: []string{"T"}, - Usage: "disable tag tracking and tag page generation", - Value: false, - }, - &cli.BoolFlag{ - Name: DisableReadingTimeFlagName, - Usage: "disable reading time estimation on posts", - Value: false, - }, - &cli.StringFlag{ - Name: BaseURLFlagName, - Usage: "absolute base URL of the site (e.g. https://example.com); required for RSS/Atom feed generation", - }, - &cli.BoolFlag{ - Name: DisableFeedsFlagName, - Usage: "disable RSS and Atom feed generation", - Value: false, - }, - &cli.IntFlag{ - Name: FeedLimitFlagName, - Usage: "maximum number of posts to include in each feed", - Value: 10, - }, }, } diff --git a/internal/generator/flagConsts.go b/internal/generator/flagConsts.go index 9c52778..b667d36 100644 --- a/internal/generator/flagConsts.go +++ b/internal/generator/flagConsts.go @@ -12,24 +12,3 @@ const InputPostsDirArgName = "input-posts" // OutputDirArgName is the CLI argument name for the output directory. const OutputDirArgName = "output-dir" - -// TemplateDirArgName is the CLI flag name for setting a template directory -const TemplateDirFlagName = "template-dir" - -// BlogRootFlagName is the CLI flag name for setting a blog root path -const BlogRootFlagName = "root-path" - -// DisableTagsFlagName is the CLI flag name for disabling tag page generation. -const DisableTagsFlagName = "disable-tags" - -// DisableReadingTimeFlagName is the CLI flag name for disabling reading time estimation. -const DisableReadingTimeFlagName = "disable-reading-time" - -// BaseURLFlagName is the CLI flag name for setting the site's absolute base URL (required for feed generation). -const BaseURLFlagName = "base-url" - -// DisableFeedsFlagName is the CLI flag name for disabling RSS and Atom feed generation. -const DisableFeedsFlagName = "disable-feeds" - -// FeedLimitFlagName is the CLI flag name for setting the maximum number of posts in each feed. -const FeedLimitFlagName = "feed-limit" diff --git a/internal/generator/generator.go b/internal/generator/generator.go index e85c72b..3dafb8c 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" + "github.com/harrydayexe/GoBlog/v2/internal/cliflags" "github.com/harrydayexe/GoBlog/v2/internal/utilities" "github.com/harrydayexe/GoBlog/v2/pkg/config" "github.com/harrydayexe/GoBlog/v2/pkg/generator" @@ -51,27 +52,27 @@ func NewGeneratorCommand(ctx context.Context, c *cli.Command) error { opts = append(opts, config.WithRawOutput()) } - if c.Bool(DisableTagsFlagName) { + if c.Bool(cliflags.DisableTagsFlagName) { opts = append(opts, config.WithDisableTags()) } - if c.Bool(DisableReadingTimeFlagName) { + if c.Bool(cliflags.DisableReadingTimeFlagName) { opts = append(opts, config.WithDisableReadingTime()) } - if baseURL := c.String(BaseURLFlagName); baseURL != "" { + if baseURL := c.String(cliflags.BaseURLFlagName); baseURL != "" { opts = append(opts, config.WithBaseURL(baseURL)) } - if c.Bool(DisableFeedsFlagName) { + if c.Bool(cliflags.DisableFeedsFlagName) { opts = append(opts, config.WithDisableFeeds()) } - if feedLimit := c.Int(FeedLimitFlagName); feedLimit > 0 { + if feedLimit := c.Int(cliflags.FeedLimitFlagName); feedLimit > 0 { opts = append(opts, config.WithFeedPostLimit(feedLimit)) } - templateDirPath := c.String(TemplateDirFlagName) + templateDirPath := c.String(cliflags.TemplateDirFlagName) var templateDir fs.FS if templateDirPath == "" { slog.Default().DebugContext(ctx, "Using default templates") @@ -86,7 +87,7 @@ func NewGeneratorCommand(ctx context.Context, c *cli.Command) error { templateDir = os.DirFS(templateDirPath) } - blogRootString := c.String(BlogRootFlagName) + blogRootString := c.String(cliflags.BlogRootFlagName) if blogRootString != "" { blogRootClean := filepath.Clean(blogRootString) blogRoot := strings.TrimPrefix(blogRootClean, ".") diff --git a/internal/server/command.go b/internal/server/command.go index 3819b4e..c831b42 100644 --- a/internal/server/command.go +++ b/internal/server/command.go @@ -36,28 +36,6 @@ var ServeCommand cli.Command = cli.Command{ Aliases: []string{"H"}, Usage: "host address to bind to", }, - &cli.StringFlag{ - Name: TemplateDirFlagName, - Aliases: []string{"t"}, - Usage: "directory of templates to use when rendering", - }, - &cli.StringFlag{ - Name: BlogRootFlagName, - Aliases: []string{"p"}, - Usage: "root path of the blog, defaults to '/'", - Value: "/", - }, - &cli.BoolFlag{ - Name: DisableTagsFlagName, - Aliases: []string{"T"}, - Usage: "disable tag tracking and tag page generation", - Value: false, - }, - &cli.BoolFlag{ - Name: DisableReadingTimeFlagName, - Usage: "disable reading time estimation on posts", - Value: false, - }, &cli.BoolFlag{ Name: WatchFlagName, Aliases: []string{"w"}, diff --git a/internal/server/command_test.go b/internal/server/command_test.go index a969b5d..3e25ba3 100644 --- a/internal/server/command_test.go +++ b/internal/server/command_test.go @@ -221,3 +221,57 @@ This is a new post. } t.Error("new post not available after 5s of watching") } + +// TestRunServe_ServesFeedsWithBaseURL verifies that the server exposes +// /rss.xml and /atom.xml when the generator is configured with a base URL. +func TestRunServe_ServesFeedsWithBaseURL(t *testing.T) { + t.Parallel() + + cfg := config.ServerConfig{ + Server: []config.BaseServerOption{config.WithPort(0)}, + Gen: []config.GeneratorOption{ + config.WithBaseURL("https://example.com"), + }, + } + + srv, err := pkgserver.New(discardLogger(), testFS(), cfg) + if err != nil { + t.Fatalf("server.New() error = %v", err) + } + + for _, path := range []string{"/rss.xml", "/atom.xml"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("GET %s status = %d, want %d", path, rec.Code, http.StatusOK) + } + } +} + +// TestRunServe_FeedsNotAvailableWithoutBaseURL verifies that feed routes return +// 404 when no base URL is configured (feeds are not generated). +func TestRunServe_FeedsNotAvailableWithoutBaseURL(t *testing.T) { + t.Parallel() + + cfg := config.ServerConfig{ + Server: []config.BaseServerOption{config.WithPort(0)}, + // No WithBaseURL → generator skips feed generation. + } + + srv, err := pkgserver.New(discardLogger(), testFS(), cfg) + if err != nil { + t.Fatalf("server.New() error = %v", err) + } + + for _, path := range []string{"/rss.xml", "/atom.xml"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("GET %s (no base-url) status = %d, want %d", path, rec.Code, http.StatusNotFound) + } + } +} diff --git a/internal/server/flagConsts.go b/internal/server/flagConsts.go index e13a46a..e419ae2 100644 --- a/internal/server/flagConsts.go +++ b/internal/server/flagConsts.go @@ -13,18 +13,6 @@ const PortFlagName = "port" // HostFlagName is the CLI flag name for setting the HTTP bind address. const HostFlagName = "host" -// TemplateDirFlagName is the CLI flag name for setting a template directory. -const TemplateDirFlagName = "template-dir" - -// BlogRootFlagName is the CLI flag name for setting a blog root path. -const BlogRootFlagName = "root-path" - -// DisableTagsFlagName is the CLI flag name for disabling tag page generation. -const DisableTagsFlagName = "disable-tags" - -// DisableReadingTimeFlagName is the CLI flag name for disabling reading time estimation. -const DisableReadingTimeFlagName = "disable-reading-time" - // WatchFlagName is the CLI flag name for enabling filesystem watching. const WatchFlagName = "watch" diff --git a/internal/server/server.go b/internal/server/server.go index c797dd5..4548112 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "path" "strings" + "github.com/harrydayexe/GoBlog/v2/internal/cliflags" "github.com/harrydayexe/GoBlog/v2/internal/utilities" "github.com/harrydayexe/GoBlog/v2/pkg/config" "github.com/harrydayexe/GoBlog/v2/pkg/server" @@ -44,13 +45,26 @@ func NewServeCommand(ctx context.Context, c *cli.Command) error { cfg.Gen = append(cfg.Gen, config.WithEnvironment(string(envCfg.Environment))) - if c.Bool(DisableTagsFlagName) { + if c.Bool(cliflags.DisableTagsFlagName) { cfg.Gen = append(cfg.Gen, config.WithDisableTags()) } - if c.Bool(DisableReadingTimeFlagName) { + if c.Bool(cliflags.DisableReadingTimeFlagName) { cfg.Gen = append(cfg.Gen, config.WithDisableReadingTime()) } + + if baseURL := c.String(cliflags.BaseURLFlagName); baseURL != "" { + cfg.Gen = append(cfg.Gen, config.WithBaseURL(baseURL)) + } + + if c.Bool(cliflags.DisableFeedsFlagName) { + cfg.Gen = append(cfg.Gen, config.WithDisableFeeds()) + } + + if feedLimit := c.Int(cliflags.FeedLimitFlagName); feedLimit > 0 { + cfg.Gen = append(cfg.Gen, config.WithFeedPostLimit(feedLimit)) + } + cfg.Server = append(cfg.Server, config.WithPort(c.Int(PortFlagName))) cfg.Server = append(cfg.Server, config.WithCacheControl(c.Duration(CacheControlFlagName))) @@ -58,7 +72,7 @@ func NewServeCommand(ctx context.Context, c *cli.Command) error { cfg.Server = append(cfg.Server, config.WithHost(host)) } - templateDirPath := c.String(TemplateDirFlagName) + templateDirPath := c.String(cliflags.TemplateDirFlagName) if templateDirPath == "" { slog.Default().DebugContext(ctx, "Using default templates") cfg.TemplateDir = templates.Default @@ -71,7 +85,7 @@ func NewServeCommand(ctx context.Context, c *cli.Command) error { cfg.TemplateDir = os.DirFS(templateDirPath) } - blogRootString := c.String(BlogRootFlagName) + blogRootString := c.String(cliflags.BlogRootFlagName) if blogRootString != "" { blogRoot := path.Clean(blogRootString) blogRoot = strings.TrimPrefix(blogRoot, ".") diff --git a/pkg/server/doc.go b/pkg/server/doc.go index 0b7917c..79a3cc2 100644 --- a/pkg/server/doc.go +++ b/pkg/server/doc.go @@ -42,6 +42,20 @@ // log.Fatal(err) // } // +// # Feed Routes +// +// When the generator is configured with config.WithBaseURL (and +// config.WithDisableFeeds is not applied), the server exposes: +// +// - GET {root}rss.xml — site-wide RSS 2.0 feed +// - GET {root}atom.xml — site-wide Atom feed +// - GET {root}tags/{tag}.rss.xml — per-tag RSS 2.0 feed +// - GET {root}tags/{tag}.atom.xml — per-tag Atom feed +// +// Feed routes always exist in the mux but return 404 when the generator did +// not produce feed content. This mirrors the static output written by +// pkg/outputter at rss.xml, atom.xml, tags/{tag}.rss.xml, tags/{tag}.atom.xml. +// // # HTML Extension Handling // // The server automatically accepts requests with or without .html suffixes. diff --git a/pkg/server/feed_test.go b/pkg/server/feed_test.go new file mode 100644 index 0000000..58819cc --- /dev/null +++ b/pkg/server/feed_test.go @@ -0,0 +1,214 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package server_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/harrydayexe/GoBlog/v2/pkg/config" + "github.com/harrydayexe/GoBlog/v2/pkg/generator" + "github.com/harrydayexe/GoBlog/v2/pkg/server" +) + +var fakeRSS = []byte(``) +var fakeAtom = []byte(``) + +// testBlogWithFeeds returns a minimal GeneratedBlog populated with feed content +// for the given tag name. +func testBlogWithFeeds(tag string) *generator.GeneratedBlog { + blog := generator.NewEmptyGeneratedBlog() + blog.RSSFeed = fakeRSS + blog.AtomFeed = fakeAtom + if tag != "" { + blog.Tags[tag] = []byte("tag page") + blog.TagRSSFeeds[tag] = fakeRSS + blog.TagAtomFeeds[tag] = fakeAtom + } + return blog +} + +// TestHandler_SiteRSSFeed verifies that GET /rss.xml returns 200 with the +// correct Content-Type when the generator produced a feed. +func TestHandler_SiteRSSFeed(t *testing.T) { + t.Parallel() + + blog := testBlogWithFeeds("") + h := server.Handler(blog, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/rss.xml", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("GET /rss.xml status = %d, want %d", rec.Code, http.StatusOK) + } + ct := rec.Header().Get("Content-Type") + if ct != "application/rss+xml; charset=utf-8" { + t.Errorf("Content-Type = %q, want %q", ct, "application/rss+xml; charset=utf-8") + } +} + +// TestHandler_SiteAtomFeed verifies that GET /atom.xml returns 200 with the +// correct Content-Type when the generator produced a feed. +func TestHandler_SiteAtomFeed(t *testing.T) { + t.Parallel() + + blog := testBlogWithFeeds("") + h := server.Handler(blog, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/atom.xml", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("GET /atom.xml status = %d, want %d", rec.Code, http.StatusOK) + } + ct := rec.Header().Get("Content-Type") + if ct != "application/atom+xml; charset=utf-8" { + t.Errorf("Content-Type = %q, want %q", ct, "application/atom+xml; charset=utf-8") + } +} + +// TestHandler_SiteRSSFeed_Empty returns 404 when the generator did not produce +// a feed (e.g. base-url not set or feeds disabled). +func TestHandler_SiteRSSFeed_Empty(t *testing.T) { + t.Parallel() + + blog := generator.NewEmptyGeneratedBlog() // no feed bytes + h := server.Handler(blog, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/rss.xml", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("GET /rss.xml (empty) status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +// TestHandler_SiteAtomFeed_Empty returns 404 when the generator did not produce +// a feed. +func TestHandler_SiteAtomFeed_Empty(t *testing.T) { + t.Parallel() + + blog := generator.NewEmptyGeneratedBlog() // no feed bytes + h := server.Handler(blog, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/atom.xml", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("GET /atom.xml (empty) status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +// TestHandler_TagRSSFeed verifies that GET /tags/{tag}.rss.xml returns 200 +// with the correct Content-Type when the generator produced a per-tag feed. +func TestHandler_TagRSSFeed(t *testing.T) { + t.Parallel() + + blog := testBlogWithFeeds("golang") + h := server.Handler(blog, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tags/golang.rss.xml", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("GET /tags/golang.rss.xml status = %d, want %d", rec.Code, http.StatusOK) + } + ct := rec.Header().Get("Content-Type") + if ct != "application/rss+xml; charset=utf-8" { + t.Errorf("Content-Type = %q, want %q", ct, "application/rss+xml; charset=utf-8") + } +} + +// TestHandler_TagAtomFeed verifies that GET /tags/{tag}.atom.xml returns 200 +// with the correct Content-Type. +func TestHandler_TagAtomFeed(t *testing.T) { + t.Parallel() + + blog := testBlogWithFeeds("golang") + h := server.Handler(blog, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tags/golang.atom.xml", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("GET /tags/golang.atom.xml status = %d, want %d", rec.Code, http.StatusOK) + } + ct := rec.Header().Get("Content-Type") + if ct != "application/atom+xml; charset=utf-8" { + t.Errorf("Content-Type = %q, want %q", ct, "application/atom+xml; charset=utf-8") + } +} + +// TestHandler_TagRSSFeed_UnknownTag returns 404 for an unknown tag. +func TestHandler_TagRSSFeed_UnknownTag(t *testing.T) { + t.Parallel() + + blog := testBlogWithFeeds("golang") + h := server.Handler(blog, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tags/rust.rss.xml", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("GET /tags/rust.rss.xml (unknown) status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +// TestHandler_TagAtomFeed_UnknownTag returns 404 for an unknown tag. +func TestHandler_TagAtomFeed_UnknownTag(t *testing.T) { + t.Parallel() + + blog := testBlogWithFeeds("golang") + h := server.Handler(blog, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tags/rust.atom.xml", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("GET /tags/rust.atom.xml (unknown) status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +// TestHandler_FeedsWithBlogRoot verifies that feed routes are served under the +// blog root when one is configured. +func TestHandler_FeedsWithBlogRoot(t *testing.T) { + t.Parallel() + + blog := testBlogWithFeeds("golang") + h := server.Handler(blog, nil, config.WithBlogRoot("/blog/")) + + tests := []struct { + path string + want int + }{ + {"/blog/rss.xml", http.StatusOK}, + {"/blog/atom.xml", http.StatusOK}, + {"/blog/tags/golang.rss.xml", http.StatusOK}, + {"/blog/tags/golang.atom.xml", http.StatusOK}, + // Without the prefix should not match. + {"/rss.xml", http.StatusNotFound}, + {"/atom.xml", http.StatusNotFound}, + } + + for _, tc := range tests { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + h.ServeHTTP(rec, req) + + if rec.Code != tc.want { + t.Errorf("GET %s status = %d, want %d", tc.path, rec.Code, tc.want) + } + } +} diff --git a/pkg/server/handler.go b/pkg/server/handler.go index 8be05d1..18cfe53 100644 --- a/pkg/server/handler.go +++ b/pkg/server/handler.go @@ -31,11 +31,19 @@ type HandlerConfig struct { // - GET /posts/{postName} - serves individual blog posts // - GET /tags - serves the tags index page (only if blog.TagsIndex is non-empty) // - GET /tags/{tagName} - serves tag-specific pages (only if blog.Tags is non-empty) +// - GET /rss.xml - serves the site-wide RSS 2.0 feed (404 when feeds are disabled) +// - GET /atom.xml - serves the site-wide Atom feed (404 when feeds are disabled) +// - GET /tags/{tagName}.rss.xml - serves a per-tag RSS 2.0 feed (only if blog.Tags is non-empty) +// - GET /tags/{tagName}.atom.xml - serves a per-tag Atom feed (only if blog.Tags is non-empty) // // Tag routes are registered only when the blog contains tag content. When the // generator is configured with config.WithDisableTags(), blog.Tags and // blog.TagsIndex will be empty and the tag routes will not be registered. // +// Feed routes are always registered, but return 404 when the generator did not +// produce feed content (i.e. when config.WithBaseURL was not set, or +// config.WithDisableFeeds was applied). +// // The returned handler automatically strips .html suffixes from incoming request // paths via middleware.NewStripHTMLExtension, so both /posts/foo and // /posts/foo.html are routed to the same handler. @@ -101,6 +109,9 @@ func generateHandler(cfg HandlerConfig, blog *generator.GeneratedBlog) http.Hand mux.Handle(root+"tags/{tagName}", handleTag(cfg, blog)) } + mux.Handle(root+"rss.xml", handleRSSFeed(cfg, blog)) + mux.Handle(root+"atom.xml", handleAtomFeed(cfg, blog)) + return mux } @@ -153,7 +164,37 @@ func handleTag(cfg HandlerConfig, blog *generator.GeneratedBlog) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cfg.Logger.Logger.DebugContext(r.Context(), "handling tag page") - tagName := strings.TrimSuffix(r.PathValue("tagName"), ".html") + rawName := r.PathValue("tagName") + + // Serve per-tag feeds when the path ends with a feed extension. + if tag, ok := strings.CutSuffix(rawName, ".rss.xml"); ok { + bits := blog.TagRSSFeeds[tag] + if len(bits) == 0 { + w.WriteHeader(http.StatusNotFound) + return + } + cfg.Logger.Logger.DebugContext(r.Context(), "serving tag RSS feed", slog.String("tag", tag)) + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + if _, err := w.Write(bits); err != nil { + cfg.Logger.Logger.ErrorContext(r.Context(), "failed to write tag RSS feed", "error", err, "tag", tag) + } + return + } + if tag, ok := strings.CutSuffix(rawName, ".atom.xml"); ok { + bits := blog.TagAtomFeeds[tag] + if len(bits) == 0 { + w.WriteHeader(http.StatusNotFound) + return + } + cfg.Logger.Logger.DebugContext(r.Context(), "serving tag Atom feed", slog.String("tag", tag)) + w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") + if _, err := w.Write(bits); err != nil { + cfg.Logger.Logger.ErrorContext(r.Context(), "failed to write tag Atom feed", "error", err, "tag", tag) + } + return + } + + tagName := strings.TrimSuffix(rawName, ".html") bits, prs := blog.Tags[tagName] if !prs { w.WriteHeader(http.StatusNotFound) @@ -169,3 +210,35 @@ func handleTag(cfg HandlerConfig, blog *generator.GeneratedBlog) http.Handler { } }) } + +func handleRSSFeed(cfg HandlerConfig, blog *generator.GeneratedBlog) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cfg.Logger.Logger.DebugContext(r.Context(), "handling site RSS feed") + + if len(blog.RSSFeed) == 0 { + w.WriteHeader(http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + if _, err := w.Write(blog.RSSFeed); err != nil { + cfg.Logger.Logger.ErrorContext(r.Context(), "failed to write RSS feed", "error", err) + } + }) +} + +func handleAtomFeed(cfg HandlerConfig, blog *generator.GeneratedBlog) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cfg.Logger.Logger.DebugContext(r.Context(), "handling site Atom feed") + + if len(blog.AtomFeed) == 0 { + w.WriteHeader(http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") + if _, err := w.Write(blog.AtomFeed); err != nil { + cfg.Logger.Logger.ErrorContext(r.Context(), "failed to write Atom feed", "error", err) + } + }) +} From 845e01ad8c760f27bb0ea78bc141054e44104988 Mon Sep 17 00:00:00 2001 From: Harry Day Date: Sat, 20 Jun 2026 21:30:54 +0100 Subject: [PATCH 7/7] fix(generator): make feed-limit 0 unlimited and clarify base-url scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the library-side 0→10 coercion in generator.New so that WithFeedPostLimit(0) (and the unset default) now means unlimited. The 10-post default is retained in the CLI flag value; both generate and serve subcommands now pass the flag value unconditionally. Clarify in godoc and README that WithBaseURL must be scheme+host only — combining a path here with WithBlogRoot produces doubled segments in every feed URL. Also extract effectiveUpdated() helper to deduplicate the LastEdited-vs-Date logic, expand the handleTag comment to explain why feed demuxing lives inside the wildcard handler, and fix a stale AbsURL→absURL error message in the test. --- README.md | 4 +- internal/cliflags/flags.go | 2 +- internal/generator/generator.go | 4 +- internal/server/server.go | 4 +- pkg/config/generatorOption.go | 24 +++++++---- pkg/generator/feeds.go | 24 +++++------ pkg/generator/feeds_test.go | 74 ++++++++++++++++++++++++++++++++- pkg/generator/generator.go | 4 -- pkg/server/handler.go | 4 ++ 9 files changed, 111 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 44bbfd1..8c4bb4d 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ These flags apply to both `generate` and `serve` and may be passed before or aft | `--root-path` | `-p` | `/` | Blog root path for subdirectory deployment | | `--disable-tags` | `-T` | `false` | Disable tag tracking and tag page generation | | `--disable-reading-time` | | `false` | Disable reading time estimation on posts | -| `--base-url` | | _(none)_ | Absolute base URL of the site (e.g. `https://example.com`); required to generate RSS/Atom feeds | +| `--base-url` | | _(none)_ | Scheme + host of the site (e.g. `https://example.com`); required to generate RSS/Atom feeds. Must not include a path — use `--root-path` for subdirectory deployments | | `--disable-feeds` | | `false` | Disable RSS and Atom feed generation | -| `--feed-limit` | | `10` | Maximum number of posts to include in each feed | +| `--feed-limit` | | `10` | Maximum number of posts to include in each feed (`0` = unlimited) | ### `generate` flags diff --git a/internal/cliflags/flags.go b/internal/cliflags/flags.go index 5f1417c..0abf253 100644 --- a/internal/cliflags/flags.go +++ b/internal/cliflags/flags.go @@ -73,7 +73,7 @@ func Shared() []cli.Flag { }, &cli.IntFlag{ Name: FeedLimitFlagName, - Usage: "maximum number of posts to include in each feed", + Usage: "maximum number of posts to include in each feed (0 = unlimited)", Value: 10, }, } diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 3dafb8c..3ee28a3 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -68,9 +68,7 @@ func NewGeneratorCommand(ctx context.Context, c *cli.Command) error { opts = append(opts, config.WithDisableFeeds()) } - if feedLimit := c.Int(cliflags.FeedLimitFlagName); feedLimit > 0 { - opts = append(opts, config.WithFeedPostLimit(feedLimit)) - } + opts = append(opts, config.WithFeedPostLimit(c.Int(cliflags.FeedLimitFlagName))) templateDirPath := c.String(cliflags.TemplateDirFlagName) var templateDir fs.FS diff --git a/internal/server/server.go b/internal/server/server.go index 4548112..3c3fce6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -61,9 +61,7 @@ func NewServeCommand(ctx context.Context, c *cli.Command) error { cfg.Gen = append(cfg.Gen, config.WithDisableFeeds()) } - if feedLimit := c.Int(cliflags.FeedLimitFlagName); feedLimit > 0 { - cfg.Gen = append(cfg.Gen, config.WithFeedPostLimit(feedLimit)) - } + cfg.Gen = append(cfg.Gen, config.WithFeedPostLimit(c.Int(cliflags.FeedLimitFlagName))) cfg.Server = append(cfg.Server, config.WithPort(c.Int(PortFlagName))) cfg.Server = append(cfg.Server, config.WithCacheControl(c.Duration(CacheControlFlagName))) diff --git a/pkg/config/generatorOption.go b/pkg/config/generatorOption.go index bace0a0..f26fc3d 100644 --- a/pkg/config/generatorOption.go +++ b/pkg/config/generatorOption.go @@ -356,7 +356,13 @@ func (o CustomData) AsOption() GeneratorOption { } // BaseURL is a configuration type that holds the absolute base URL of the site -// (e.g. "https://example.com" or "https://example.com/blog"). +// (e.g. "https://example.com"). +// +// BaseURL must contain only the scheme and host — do not include a path. If +// the blog is deployed under a sub-path (e.g. "https://example.com/blog"), +// use [WithBlogRoot] for the path portion; it is appended automatically when +// building feed URLs. Including a path in BaseURL produces duplicated path +// segments (e.g. "…/blog/blog/posts/…"). // // BaseURL is required for feed generation: RSS and Atom items must reference // fully-qualified URLs. When BaseURL is empty, the generator skips feed @@ -369,9 +375,10 @@ type BaseURL string // WithBaseURL returns a GeneratorOption that sets the site's absolute base URL. // // The base URL is prepended to site-relative paths to produce fully-qualified -// URLs for RSS/Atom feed items. It must include the scheme and host -// (e.g. "https://example.com") and may include a path prefix that matches -// [BlogRoot] when the blog is not at the domain root. +// URLs for RSS/Atom feed items. It must contain only the scheme and host +// (e.g. "https://example.com"). If the blog lives under a sub-path, configure +// that via [WithBlogRoot] — including the path in the base URL produces +// duplicated path segments in every feed URL. // // Trailing slashes are trimmed before the base URL is used, so // "https://example.com/" and "https://example.com" are equivalent. @@ -450,7 +457,8 @@ func (o DisableFeeds) AsOption() GeneratorOption { // Posts are selected in reverse-chronological order (newest first), so a // limit of 10 means only the 10 most recent posts appear in each feed. // -// The zero value (unset) is treated as 10 by the generator. +// A zero or negative value means no limit: all posts are included. When unset +// the library applies no limit; the goblog CLI defaults to 10. // // This type is typically embedded in generator configuration structs and // should be set using the [WithFeedPostLimit] option function. @@ -459,8 +467,10 @@ type FeedPostLimit struct{ Limit int } // WithFeedPostLimit returns a GeneratorOption that sets the maximum number of // posts included in each generated feed. // -// When not set, the generator defaults to 10 posts per feed. -// The same limit applies to the site-wide feed and every per-tag feed. +// A value of 0 or any negative number means unlimited — all posts are +// included. The same limit applies to the site-wide feed and every per-tag +// feed. When this option is not set, the library applies no limit (the goblog +// CLI uses a default of 10). // // Example usage: // diff --git a/pkg/generator/feeds.go b/pkg/generator/feeds.go index 48e19f6..0d98be3 100644 --- a/pkg/generator/feeds.go +++ b/pkg/generator/feeds.go @@ -7,6 +7,7 @@ package generator import ( "fmt" "strings" + "time" "github.com/gorilla/feeds" "github.com/harrydayexe/GoBlog/v2/pkg/models" @@ -54,11 +55,7 @@ func (g *Generator) buildFeedsForTitle(posts models.PostList, title string) (rss // posts[0].Date would miss edits to older posts. updated := posts[0].Date for _, p := range posts { - eff := p.Date - if !p.LastEdited.IsZero() { - eff = p.LastEdited - } - if eff.After(updated) { + if eff := effectiveUpdated(p); eff.After(updated) { updated = eff } } @@ -75,12 +72,6 @@ func (g *Generator) buildFeedsForTitle(posts models.PostList, title string) (rss for _, post := range posts { postURL := absPostURL(post.Slug) - // Use LastEdited as the updated time when set; otherwise fall back to Date. - itemUpdated := post.Date - if !post.LastEdited.IsZero() { - itemUpdated = post.LastEdited - } - item := &feeds.Item{ // The post URL serves as the unique, permanent identifier. Id: postURL, @@ -89,7 +80,7 @@ func (g *Generator) buildFeedsForTitle(posts models.PostList, title string) (rss Description: post.Description, Content: string(post.Content), Created: post.Date, - Updated: itemUpdated, + Updated: effectiveUpdated(post), } if post.Author != "" { @@ -112,6 +103,15 @@ func (g *Generator) buildFeedsForTitle(posts models.PostList, title string) (rss return []byte(rssStr), []byte(atomStr), nil } +// effectiveUpdated returns the post's last-modified instant: LastEdited when +// set, otherwise the publication Date. +func effectiveUpdated(p *models.Post) time.Time { + if !p.LastEdited.IsZero() { + return p.LastEdited + } + return p.Date +} + // absURL returns the absolute URL for a site-relative path by prepending the // base URL (trailing slash trimmed). func absURL(baseURL, path string) string { diff --git a/pkg/generator/feeds_test.go b/pkg/generator/feeds_test.go index 0934e79..3e4fb5c 100644 --- a/pkg/generator/feeds_test.go +++ b/pkg/generator/feeds_test.go @@ -7,6 +7,7 @@ package generator import ( "context" "encoding/xml" + "fmt" "strings" "testing" "testing/fstest" @@ -315,7 +316,7 @@ func TestAbsURL(t *testing.T) { t.Parallel() got := absURL(tt.base, tt.path) if got != tt.want { - t.Errorf("AbsURL(%q, %q) = %q, want %q", tt.base, tt.path, got, tt.want) + t.Errorf("absURL(%q, %q) = %q, want %q", tt.base, tt.path, got, tt.want) } }) } @@ -520,3 +521,74 @@ func TestGenerator_FeedsEnabledInBaseData(t *testing.T) { t.Error("index page should have RSS discovery link when base URL is set") } } + +// TestGenerator_FeedPostLimitZeroIsUnlimited verifies that WithFeedPostLimit(0) +// includes all posts — i.e. 0 means unlimited, not "use default". +func TestGenerator_FeedPostLimitZeroIsUnlimited(t *testing.T) { + t.Parallel() + + // Build 12 posts — more than the old library default of 10. + postsFS := fstest.MapFS{} + for i := range 12 { + name := fmt.Sprintf("post%02d.md", i+1) + date := time.Date(2024, time.January, 12-i, 0, 0, 0, 0, time.UTC) + postsFS[name] = &fstest.MapFile{ + Data: []byte(fmt.Sprintf("---\ntitle: \"Post %d\"\ndate: %s\ndescription: \"p%d\"\n---\ncontent %d", + i+1, date.Format("2006-01-02"), i+1, i+1)), + } + } + + gen := New(postsFS, newTestRenderer(t), + config.WithBaseURL("https://example.com"), + config.WithFeedPostLimit(0), // explicit 0 = unlimited + ) + + blog, err := gen.Generate(context.Background()) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + rss := string(blog.RSSFeed) + for i := range 12 { + slug := fmt.Sprintf("post-%d", i+1) + if !strings.Contains(rss, "https://example.com/posts/"+slug) { + t.Errorf("post %d should be in feed when limit is 0 (unlimited)", i+1) + } + } +} + +// TestGenerator_NoFeedPostLimitOptionIsUnlimited verifies that omitting +// WithFeedPostLimit entirely includes all posts at the library level — +// the 10-post default lives only in the CLI flag, not in generator.New. +func TestGenerator_NoFeedPostLimitOptionIsUnlimited(t *testing.T) { + t.Parallel() + + // Build 12 posts — more than the old library default of 10. + postsFS := fstest.MapFS{} + for i := range 12 { + name := fmt.Sprintf("post%02d.md", i+1) + date := time.Date(2024, time.January, 12-i, 0, 0, 0, 0, time.UTC) + postsFS[name] = &fstest.MapFile{ + Data: []byte(fmt.Sprintf("---\ntitle: \"Post %d\"\ndate: %s\ndescription: \"p%d\"\n---\ncontent %d", + i+1, date.Format("2006-01-02"), i+1, i+1)), + } + } + + // No WithFeedPostLimit option at all. + gen := New(postsFS, newTestRenderer(t), + config.WithBaseURL("https://example.com"), + ) + + blog, err := gen.Generate(context.Background()) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + rss := string(blog.RSSFeed) + for i := range 12 { + slug := fmt.Sprintf("post-%d", i+1) + if !strings.Contains(rss, "https://example.com/posts/"+slug) { + t.Errorf("post %d should be in feed when no limit option is set", i+1) + } + } +} diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go index 257d715..debd598 100644 --- a/pkg/generator/generator.go +++ b/pkg/generator/generator.go @@ -130,10 +130,6 @@ func New(posts fs.FS, renderer *TemplateRenderer, opts ...config.GeneratorOption gen.Environment = config.Environment{Environment: "local"} } - if gen.FeedPostLimit.Limit == 0 { - gen.FeedPostLimit = config.FeedPostLimit{Limit: 10} - } - return &gen } diff --git a/pkg/server/handler.go b/pkg/server/handler.go index 18cfe53..ee9e7b6 100644 --- a/pkg/server/handler.go +++ b/pkg/server/handler.go @@ -167,6 +167,10 @@ func handleTag(cfg HandlerConfig, blog *generator.GeneratedBlog) http.Handler { rawName := r.PathValue("tagName") // Serve per-tag feeds when the path ends with a feed extension. + // Go's net/http wildcard {tagName} captures the entire final path + // segment, including any ".rss.xml" / ".atom.xml" suffix, so feed + // paths cannot be registered as their own mux patterns and must be + // demuxed here by inspecting the captured name. if tag, ok := strings.CutSuffix(rawName, ".rss.xml"); ok { bits := blog.TagRSSFeeds[tag] if len(bits) == 0 {