diff --git a/README.md b/README.md index 1a10b4a..8c4bb4d 100644 --- a/README.md +++ b/README.md @@ -24,26 +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)_ | 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 (`0` = unlimited) | + +### `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/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/cliflags/flags.go b/internal/cliflags/flags.go new file mode 100644 index 0000000..0abf253 --- /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 (0 = unlimited)", + Value: 10, + }, + } +} diff --git a/internal/generator/command.go b/internal/generator/command.go index 3cb3211..06467fc 100644 --- a/internal/generator/command.go +++ b/internal/generator/command.go @@ -34,27 +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, - }, }, } diff --git a/internal/generator/flagConsts.go b/internal/generator/flagConsts.go index 29582a5..b667d36 100644 --- a/internal/generator/flagConsts.go +++ b/internal/generator/flagConsts.go @@ -12,15 +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" diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 310e0d7..3ee28a3 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,15 +52,25 @@ 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()) } - templateDirPath := c.String(TemplateDirFlagName) + if baseURL := c.String(cliflags.BaseURLFlagName); baseURL != "" { + opts = append(opts, config.WithBaseURL(baseURL)) + } + + if c.Bool(cliflags.DisableFeedsFlagName) { + opts = append(opts, config.WithDisableFeeds()) + } + + opts = append(opts, config.WithFeedPostLimit(c.Int(cliflags.FeedLimitFlagName))) + + templateDirPath := c.String(cliflags.TemplateDirFlagName) var templateDir fs.FS if templateDirPath == "" { slog.Default().DebugContext(ctx, "Using default templates") @@ -74,7 +85,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..3c3fce6 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,24 @@ 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()) + } + + 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))) @@ -58,7 +70,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 +83,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/config/generatorOption.go b/pkg/config/generatorOption.go index c7c0bd8..f26fc3d 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,139 @@ 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"). +// +// 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 +// 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 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. +// +// 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. +// +// 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. +type FeedPostLimit struct{ Limit int } + +// WithFeedPostLimit returns a GeneratorOption that sets the maximum number of +// posts included in each generated 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: +// +// 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..0d98be3 --- /dev/null +++ b/pkg/generator/feeds.go @@ -0,0 +1,119 @@ +// 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" + "time" + + "github.com/gorilla/feeds" + "github.com/harrydayexe/GoBlog/v2/pkg/models" +) + +// 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. 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.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) +} + +// 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 + } + + 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 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 { + if eff := effectiveUpdated(p); eff.After(updated) { + updated = eff + } + } + + feed := &feeds.Feed{ + Title: title, + Link: &feeds.Link{Href: siteURL}, + Description: title + " — RSS Feed", + Created: posts[0].Date, + Updated: updated, + } + + feed.Items = make([]*feeds.Item, 0, len(posts)) + for _, post := range posts { + postURL := absPostURL(post.Slug) + + 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, + Content: string(post.Content), + Created: post.Date, + Updated: effectiveUpdated(post), + } + + 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 +} + +// 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 { + 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..3e4fb5c --- /dev/null +++ b/pkg/generator/feeds_test.go @@ -0,0 +1,594 @@ +// 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" + "fmt" + "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 +} + +// 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 ------------------------------------------------------- + +func TestBuildFeeds_EmptyPosts(t *testing.T) { + t.Parallel() + + gen := newFeedsGen("https://example.com", "Blog", 10) + rss, atom, err := gen.buildFeeds(nil) + 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() + + gen := newFeedsGen("https://example.com", "Test Blog", 10) + posts := makePosts(3) + rss, atom, err := gen.buildFeeds(posts) + 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() + + gen := newFeedsGen("https://myblog.com", "My Blog", 10) + posts := makePosts(2) + rss, atom, err := gen.buildFeeds(posts) + 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() + + gen := newFeedsGen("https://example.com", "Blog", 5) + posts := makePosts(15) + rss, _, err := gen.buildFeeds(posts) + 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() + + gen := newFeedsGen("https://example.com", "Blog", 10) + posts := models.PostList{ + { + Title: "Hello", + Slug: "hello", + Date: time.Now(), + Content: []byte("

full HTML content here

"), + }, + } + + rss, atom, err := gen.buildFeeds(posts) + 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() + + 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 := gen.buildFeeds(posts) + 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) + } +} + +// ---- 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) { + 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. + 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") + } +} + +// 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/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..debd598 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) } @@ -234,6 +250,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 +273,30 @@ 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 { + rss, atom, err := g.buildFeeds(posts) + 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 +319,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 +353,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 +375,18 @@ func (g *Generator) assembleBlogWithTemplates(ctx context.Context, posts models. } blog.Tags[tag] = rendered + + // Build per-tag feeds alongside the tag page. + if feedsEnabled { + tagRSS, tagAtom, err := g.buildTagFeeds(tag, tagPosts) + 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 +406,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/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..ee9e7b6 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,41 @@ 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. + // 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 { + 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 +214,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) + } + }) +} 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}}