From a24536450fd4715eeba18fedd28c4baab5b8e449 Mon Sep 17 00:00:00 2001
From: Emma De Silva <emma@desilva.se>
Date: Mon, 13 Jul 2026 21:13:57 +0200
Subject: [PATCH 1/6] Add RssFeedPage registered through core extension page
 discovery

The RSS feed is now a route: registered as an in-memory page behind
Features::hasRss(), compiled through the standard build, and skipped when
a user-defined page already claims the configured feed route key. The
contents resolve the RssFeedGenerator from the service container at
compile time so the implementation can be swapped with a container rebind.
The configured hyde.rss.filename is used verbatim as the output path,
preserving support for filenames outside the default recognized
non-HTML extensions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 .../src/Foundation/HydeCoreExtension.php      | 13 +++++
 .../Features/XmlGenerators/RssFeedPage.php    | 49 +++++++++++++++++++
 2 files changed, 62 insertions(+)
 create mode 100644 packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php

diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php
index 4e5737a2085..7a1933580e9 100644
--- a/packages/framework/src/Foundation/HydeCoreExtension.php
+++ b/packages/framework/src/Foundation/HydeCoreExtension.php
@@ -21,6 +21,7 @@
 use Hyde\Facades\Config;
 use Hyde\Framework\Features\Documentation\DocumentationSearchPage;
 use Hyde\Framework\Features\Documentation\DocumentationSearchIndex;
+use Hyde\Framework\Features\XmlGenerators\RssFeedPage;
 use Hyde\Framework\Features\XmlGenerators\SitemapPage;
 use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersion;
 use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersions;
@@ -85,6 +86,10 @@ public function discoverPages(PageCollection $collection): void
         if (Features::hasSitemap()) {
             $this->discoverSitemapPage($collection);
         }
+
+        if (Features::hasRss()) {
+            $this->discoverRssFeedPage($collection);
+        }
     }
 
     /** Add the generated sitemap page unless the route is user-defined. */
@@ -95,6 +100,14 @@ protected function discoverSitemapPage(PageCollection $collection): void
         }
     }
 
+    /** Add the generated RSS feed page unless the route is user-defined. */
+    protected function discoverRssFeedPage(PageCollection $collection): void
+    {
+        if (! $this->hasPageWithRouteKey($collection, RssFeedPage::routeKey())) {
+            $collection->addPage(new RssFeedPage());
+        }
+    }
+
     /** Discard documentation source files stored outside the version directories. */
     protected function discardUnversionedDocumentationFiles(FileCollection $collection): void
     {
diff --git a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php
new file mode 100644
index 00000000000..e61b8b131b8
--- /dev/null
+++ b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php
@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Hyde\Framework\Features\XmlGenerators;
+
+use Hyde\Pages\InMemoryPage;
+
+use function app;
+
+/**
+ * @internal This page is used to render the RSS feed for the site.
+ *
+ * The contents are generated by the RssFeedGenerator, which is resolved from the
+ * service container when the page is compiled, so the output can be customized
+ * by rebinding the generator class in the container.
+ */
+class RssFeedPage extends InMemoryPage
+{
+    public function __construct()
+    {
+        parent::__construct(static::routeKey(), [
+            'navigation' => ['hidden' => true],
+        ]);
+    }
+
+    public function compile(): string
+    {
+        return app(RssFeedGenerator::class)->generate()->getXml();
+    }
+
+    /**
+     * Get the route key of the RSS feed, which for this page is also its output path.
+     */
+    public static function routeKey(): string
+    {
+        return RssFeedGenerator::getFilename();
+    }
+
+    /**
+     * The identifier is the user-configured `hyde.rss.filename` and is always used
+     * verbatim as the output path, regardless of its extension, so filenames like
+     * `feed.rss` outside the default recognized extensions keep working.
+     */
+    protected static function identifierHasExplicitOutputExtension(string $identifier): bool
+    {
+        return true;
+    }
+}

From 8396af95d76ed578bde82d0b672de1b51d80c031 Mon Sep 17 00:00:00 2001
From: Emma De Silva <emma@desilva.se>
Date: Mon, 13 Jul 2026 21:16:25 +0200
Subject: [PATCH 2/6] Convert the RSS feed from a post-build task to the
 registered page

Removes the GenerateRssFeed post-build task now that the feed page is
compiled through the standard build, and rewires the build:rss command to
build the registered page (or a new instance when the route is not
registered, preserving the old task behavior where the explicit command
generates the feed regardless of the feature conditions) through the
StaticPageBuilder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 .../Console/Commands/BuildRssFeedCommand.php  | 22 ++++++++++--
 .../PostBuildTasks/GenerateRssFeed.php        | 35 -------------------
 .../Framework/Services/BuildTaskService.php   |  8 -----
 3 files changed, 19 insertions(+), 46 deletions(-)
 delete mode 100644 packages/framework/src/Framework/Actions/PostBuildTasks/GenerateRssFeed.php

diff --git a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php
index 6880c50b6b6..e8703b4f646 100644
--- a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php
+++ b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php
@@ -4,8 +4,14 @@
 
 namespace Hyde\Console\Commands;
 
-use Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed;
-use LaravelZero\Framework\Commands\Command;
+use Hyde\Hyde;
+use Hyde\Console\Concerns\Command;
+use Hyde\Foundation\Facades\Routes;
+use Hyde\Framework\Actions\StaticPageBuilder;
+use Hyde\Framework\Features\XmlGenerators\RssFeedPage;
+use Hyde\Pages\Concerns\HydePage;
+
+use function sprintf;
 
 /**
  * Run the build process for the RSS feed.
@@ -20,6 +26,16 @@ class BuildRssFeedCommand extends Command
 
     public function handle(): int
     {
-        return (new GenerateRssFeed())->run($this->output);
+        $path = StaticPageBuilder::handle($this->getFeedPage());
+
+        $this->infoComment(sprintf('Created [%s]', Hyde::pathToRelative($path)));
+
+        return Command::SUCCESS;
+    }
+
+    /** Get the registered RSS feed page, falling back to a new instance when the route is not registered. */
+    protected function getFeedPage(): HydePage
+    {
+        return Routes::find(RssFeedPage::routeKey())?->getPage() ?? new RssFeedPage();
     }
 }
diff --git a/packages/framework/src/Framework/Actions/PostBuildTasks/GenerateRssFeed.php b/packages/framework/src/Framework/Actions/PostBuildTasks/GenerateRssFeed.php
deleted file mode 100644
index e974d12906b..00000000000
--- a/packages/framework/src/Framework/Actions/PostBuildTasks/GenerateRssFeed.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Hyde\Framework\Actions\PostBuildTasks;
-
-use Hyde\Hyde;
-use Hyde\Framework\Features\BuildTasks\PostBuildTask;
-use Hyde\Framework\Concerns\InteractsWithDirectories;
-use Hyde\Framework\Features\XmlGenerators\RssFeedGenerator;
-
-use function file_put_contents;
-
-class GenerateRssFeed extends PostBuildTask
-{
-    use InteractsWithDirectories;
-
-    public static string $message = 'Generating RSS feed';
-
-    protected string $path;
-
-    public function handle(): void
-    {
-        $this->path = Hyde::sitePath(RssFeedGenerator::getFilename());
-
-        $this->needsParentDirectory($this->path);
-
-        file_put_contents($this->path, RssFeedGenerator::make());
-    }
-
-    public function printFinishMessage(): void
-    {
-        $this->createdSiteFile($this->path)->withExecutionTime();
-    }
-}
diff --git a/packages/framework/src/Framework/Services/BuildTaskService.php b/packages/framework/src/Framework/Services/BuildTaskService.php
index 8ab3cb26afc..9b3589892cf 100644
--- a/packages/framework/src/Framework/Services/BuildTaskService.php
+++ b/packages/framework/src/Framework/Services/BuildTaskService.php
@@ -5,13 +5,11 @@
 namespace Hyde\Framework\Services;
 
 use Hyde\Facades\Config;
-use Hyde\Facades\Features;
 use Hyde\Facades\Filesystem;
 use Hyde\Framework\Features\BuildTasks\BuildTask;
 use Hyde\Framework\Features\BuildTasks\PreBuildTask;
 use Hyde\Framework\Features\BuildTasks\PostBuildTask;
 use Hyde\Framework\Actions\PreBuildTasks\CleanSiteDirectory;
-use Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed;
 use Hyde\Framework\Actions\PreBuildTasks\TransferMediaAssets;
 use Hyde\Framework\Actions\PostBuildTasks\GenerateBuildManifest;
 use Illuminate\Console\OutputStyle;
@@ -134,7 +132,6 @@ private function registerFrameworkTasks(): void
         $this->registerIf(CleanSiteDirectory::class, $this->canCleanSiteDirectory());
         $this->registerIf(TransferMediaAssets::class, $this->canTransferMediaAssets());
         $this->registerIf(GenerateBuildManifest::class, $this->canGenerateManifest());
-        $this->registerIf(GenerateRssFeed::class, $this->canGenerateFeed());
     }
 
     private function canCleanSiteDirectory(): bool
@@ -151,9 +148,4 @@ private function canGenerateManifest(): bool
     {
         return Config::getBool('hyde.generate_build_manifest', true);
     }
-
-    private function canGenerateFeed(): bool
-    {
-        return Features::hasRss();
-    }
 }

From 5d801a64b9a3d178701f557e7e24edbe81423350 Mon Sep 17 00:00:00 2001
From: Emma De Silva <emma@desilva.se>
Date: Mon, 13 Jul 2026 21:16:25 +0200
Subject: [PATCH 3/6] Update tests for the RSS feed build task to page
 conversion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 .../Feature/Commands/BuildRssFeedCommandTest.php     |  2 +-
 .../tests/Feature/Services/BuildTaskServiceTest.php  |  1 -
 .../tests/Feature/StaticSiteServiceTest.php          | 12 ++++++------
 .../tests/Unit/BuildTaskServiceUnitTest.php          | 11 +++--------
 4 files changed, 10 insertions(+), 16 deletions(-)

diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php
index 56b891b4740..41456ac8e43 100644
--- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php
+++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php
@@ -9,7 +9,7 @@
 use Hyde\Testing\TestCase;
 
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildRssFeedCommand::class)]
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\RssFeedPage::class)]
 class BuildRssFeedCommandTest extends TestCase
 {
     public function testRssFeedIsGeneratedWhenConditionsAreMet()
diff --git a/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php b/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php
index 4fc39f3eb62..9dc0f97e423 100644
--- a/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php
+++ b/packages/framework/tests/Feature/Services/BuildTaskServiceTest.php
@@ -19,7 +19,6 @@
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\BuildTasks\BuildTask::class)]
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\BuildTasks\PreBuildTask::class)]
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\BuildTasks\PostBuildTask::class)]
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed::class)]
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSiteCommand::class)]
 class BuildTaskServiceTest extends TestCase
 {
diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php
index 3e730a2a415..a2430623b5e 100644
--- a/packages/framework/tests/Feature/StaticSiteServiceTest.php
+++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php
@@ -215,9 +215,9 @@ public function testRssFeedIsNotGeneratedWhenConditionsAreNotMet()
         $this->withoutSiteUrl();
         config(['hyde.rss.enabled' => false]);
 
-        $this->artisan('build')
-            ->doesntExpectOutput('Generating RSS feed...')
-            ->assertExitCode(0);
+        $this->artisan('build')->assertExitCode(0);
+
+        $this->assertFileDoesNotExist(Hyde::path('_site/feed.xml'));
     }
 
     public function testRssFeedIsGeneratedWhenConditionsAreMet()
@@ -227,9 +227,9 @@ public function testRssFeedIsGeneratedWhenConditionsAreMet()
 
         Filesystem::touch('_posts/foo.md');
 
-        $this->artisan('build')
-            // ->expectsOutput('Generating RSS feed...')
-            ->assertExitCode(0);
+        $this->artisan('build')->assertExitCode(0);
+
+        $this->assertFileExists(Hyde::path('_site/feed.xml'));
 
         Filesystem::unlink('_posts/foo.md');
         Filesystem::unlink('_site/feed.xml');
diff --git a/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php b/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php
index 3264f3c6287..5ef62223993 100644
--- a/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php
+++ b/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php
@@ -8,7 +8,7 @@
 use Hyde\Foundation\HydeKernel;
 use Hyde\Foundation\Kernel\Filesystem;
 use Hyde\Framework\Actions\PostBuildTasks\GenerateBuildManifest as FrameworkGenerateBuildManifest;
-use Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed;
+use Hyde\Framework\Actions\PreBuildTasks\TransferMediaAssets;
 use Hyde\Framework\Features\BuildTasks\BuildTask;
 use Hyde\Framework\Features\BuildTasks\PostBuildTask;
 use Hyde\Framework\Features\BuildTasks\PreBuildTask;
@@ -162,11 +162,6 @@ public function testGenerateBuildManifestExtendsPostBuildTask()
         $this->assertInstanceOf(PostBuildTask::class, new FrameworkGenerateBuildManifest());
     }
 
-    public function testGenerateRssFeedExtendsPostBuildTask()
-    {
-        $this->assertInstanceOf(PostBuildTask::class, new GenerateRssFeed());
-    }
-
     public function testCanRunPreBuildTasks()
     {
         $this->can(fn () => $this->service->runPreBuildTasks(...));
@@ -276,7 +271,7 @@ public function testServiceFindsTasksInAppDirectory()
     {
         $files = [
             'app/Actions/GenerateBuildManifestBuildTask.php' => FrameworkGenerateBuildManifest::class,
-            'app/Actions/GenerateRssFeedBuildTask.php' => GenerateRssFeed::class,
+            'app/Actions/TransferMediaAssetsBuildTask.php' => TransferMediaAssets::class,
         ];
 
         $this->mockKernelFilesystem($files);
@@ -285,7 +280,7 @@ public function testServiceFindsTasksInAppDirectory()
 
         $this->assertSame([
             'Hyde\Framework\Actions\PostBuildTasks\GenerateBuildManifest',
-            'Hyde\Framework\Actions\PostBuildTasks\GenerateRssFeed',
+            'Hyde\Framework\Actions\PreBuildTasks\TransferMediaAssets',
         ], $this->service->getRegisteredTasks());
 
         $this->resetKernelInstance();

From dc1954c6ac44328960e8ab6fca54610feb95c2bc Mon Sep 17 00:00:00 2001
From: Emma De Silva <emma@desilva.se>
Date: Mon, 13 Jul 2026 21:18:30 +0200
Subject: [PATCH 4/6] Test the RSS feed page registration, customization, and
 serving paths

Covers the feature-gated route registration including the post and site URL
requirements, configured filenames used verbatim for any extension,
compilation through the build command including build manifest and route
list presence, the container rebind customization tier, user-defined pages
suppressing the generated page through both booting callbacks and
extensions, and the realtime compiler serving the feed with the XML
content type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 .../tests/Feature/RssFeedPageTest.php         | 200 ++++++++++++++++++
 .../tests/RealtimeCompilerTest.php            |  27 +++
 2 files changed, 227 insertions(+)
 create mode 100644 packages/framework/tests/Feature/RssFeedPageTest.php

diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php
new file mode 100644
index 00000000000..b743b9f8a5f
--- /dev/null
+++ b/packages/framework/tests/Feature/RssFeedPageTest.php
@@ -0,0 +1,200 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Hyde\Framework\Testing\Feature;
+
+use Hyde\Hyde;
+use Hyde\Testing\TestCase;
+use Hyde\Facades\Filesystem;
+use Hyde\Pages\InMemoryPage;
+use Hyde\Foundation\HydeKernel;
+use Hyde\Foundation\Facades\Routes;
+use Hyde\Foundation\Concerns\HydeExtension;
+use Hyde\Foundation\Kernel\PageCollection;
+use Hyde\Framework\Features\XmlGenerators\RssFeedGenerator;
+use Hyde\Framework\Features\XmlGenerators\RssFeedPage;
+use Illuminate\Support\Facades\File;
+
+/**
+ * Feature test for the RSS feed page, covering its registration through the core
+ * extension, compilation through the standard build, and the user-land override
+ * and container rebind customization paths.
+ *
+ * @see \Hyde\Framework\Testing\Feature\Services\RssFeedServiceTest
+ * @see \Hyde\Framework\Testing\Feature\Commands\BuildRssFeedCommandTest
+ */
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\RssFeedPage::class)]
+#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)]
+class RssFeedPageTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->withSiteUrl();
+        $this->file('_posts/hello-world.md', "# Hello, World!\n\nThis is the first post.");
+    }
+
+    protected function tearDown(): void
+    {
+        File::cleanDirectory(Hyde::path('_site'));
+
+        parent::tearDown();
+    }
+
+    public function testFeedPageIsRegisteredAsRouteWhenRssFeatureIsEnabled()
+    {
+        $this->assertTrue(Routes::exists('feed.xml'));
+
+        $page = Routes::get('feed.xml')->getPage();
+
+        $this->assertInstanceOf(RssFeedPage::class, $page);
+        $this->assertSame('feed.xml', $page->getOutputPath());
+        $this->assertSame('feed.xml', $page->getRouteKey());
+    }
+
+    public function testFeedPageIsNotRegisteredWithoutSiteUrl()
+    {
+        $this->withoutSiteUrl();
+
+        $this->assertFalse(Routes::exists('feed.xml'));
+    }
+
+    public function testFeedPageIsNotRegisteredWhenThereAreNoPosts()
+    {
+        Filesystem::unlink('_posts/hello-world.md');
+
+        $this->assertFalse(Routes::exists('feed.xml'));
+    }
+
+    public function testFeedPageIsNotRegisteredWhenRssIsDisabledInConfig()
+    {
+        config(['hyde.rss.enabled' => false]);
+
+        $this->assertFalse(Routes::exists('feed.xml'));
+    }
+
+    public function testFeedPageUsesConfiguredFilenameAsRouteKey()
+    {
+        config(['hyde.rss.filename' => 'blog.xml']);
+
+        $this->assertFalse(Routes::exists('feed.xml'));
+        $this->assertTrue(Routes::exists('blog.xml'));
+        $this->assertSame('blog.xml', Routes::get('blog.xml')->getPage()->getOutputPath());
+    }
+
+    public function testFeedPageUsesConfiguredFilenameVerbatimForAnyExtension()
+    {
+        config(['hyde.rss.filename' => 'feed.rss']);
+
+        $this->assertTrue(Routes::exists('feed.rss'));
+        $this->assertSame('feed.rss', Routes::get('feed.rss')->getPage()->getOutputPath());
+    }
+
+    public function testFeedPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap()
+    {
+        $page = new RssFeedPage();
+
+        $this->assertFalse($page->showInNavigation());
+        $this->assertFalse($page->showInSitemap());
+    }
+
+    public function testFeedPageCompilesUsingTheRssFeedGenerator()
+    {
+        $contents = (new RssFeedPage())->compile();
+
+        $this->assertStringStartsWith('<?xml version="1.0" encoding="UTF-8"?>', $contents);
+        $this->assertStringContainsString('<rss', $contents);
+        $this->assertStringContainsString('version="2.0"', $contents);
+        $this->assertStringContainsString('Hello, World!', $contents);
+    }
+
+    public function testRssFeedGeneratorCanBeSwappedThroughTheServiceContainer()
+    {
+        app()->bind(RssFeedGenerator::class, fn (): RssFeedGenerator => new class extends RssFeedGenerator
+        {
+            public function generate(): static
+            {
+                return $this;
+            }
+
+            public function getXml(): string
+            {
+                return 'custom generator output';
+            }
+        });
+
+        $this->assertSame('custom generator output', Routes::get('feed.xml')->getPage()->compile());
+    }
+
+    public function testBuildCommandCompilesFeedPageAsDynamicPage()
+    {
+        $this->artisan('build')
+            ->expectsOutput('Creating Dynamic Pages...')
+            ->assertExitCode(0);
+
+        $contents = file_get_contents(Hyde::path('_site/feed.xml'));
+
+        $this->assertStringStartsWith('<?xml version="1.0" encoding="UTF-8"?>', $contents);
+        $this->assertStringContainsString('<rss', $contents);
+        $this->assertStringContainsString('version="2.0"', $contents);
+
+        $this->assertStringNotContainsString('feed.xml', file_get_contents(Hyde::path('_site/sitemap.xml')));
+    }
+
+    public function testFeedPageIsIncludedInTheBuildManifest()
+    {
+        $this->artisan('build')->assertExitCode(0);
+
+        $manifest = json_decode(file_get_contents(Hyde::path('app/storage/framework/cache/build-manifest.json')), true);
+
+        $this->assertArrayHasKey('feed.xml', $manifest['pages']);
+        $this->assertSame('feed.xml', $manifest['pages']['feed.xml']['output_path']);
+    }
+
+    public function testFeedRouteIsIncludedInTheRouteList()
+    {
+        $this->artisan('route:list')
+            ->expectsOutputToContain('feed.xml')
+            ->assertExitCode(0);
+    }
+
+    public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedFeedPage()
+    {
+        Hyde::kernel()->booting(function (HydeKernel $kernel): void {
+            $kernel->pages()->addPage(new InMemoryPage('feed.xml', contents: 'user defined feed'));
+        });
+
+        $page = Routes::get('feed.xml')->getPage();
+
+        $this->assertNotInstanceOf(RssFeedPage::class, $page);
+        $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'feed.xml')->count());
+
+        $this->artisan('build')->assertExitCode(0);
+
+        $this->assertSame('user defined feed', file_get_contents(Hyde::path('_site/feed.xml')));
+    }
+
+    public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedFeedPage()
+    {
+        Hyde::kernel()->registerExtension(RssFeedPageTestExtension::class);
+
+        $page = Routes::get('feed.xml')->getPage();
+
+        $this->assertNotInstanceOf(RssFeedPage::class, $page);
+        $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'feed.xml')->count());
+
+        $this->artisan('build')->assertExitCode(0);
+
+        $this->assertSame('extension defined feed', file_get_contents(Hyde::path('_site/feed.xml')));
+    }
+}
+
+class RssFeedPageTestExtension extends HydeExtension
+{
+    public function discoverPages(PageCollection $collection): void
+    {
+        $collection->addPage(new InMemoryPage('feed.xml', contents: 'extension defined feed'));
+    }
+}
diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php
index 0d9a3e0f8f0..9b52f431a50 100644
--- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php
+++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php
@@ -368,6 +368,33 @@ public function testSitemapXmlRouteIsServedWithXmlContentType()
         $this->assertStringContainsString('<urlset', $response->body);
     }
 
+    public function testRssFeedRouteIsServedWithXmlContentType()
+    {
+        config(['hyde.url' => 'https://example.com']);
+
+        $this->mockCompilerRoute('feed.xml');
+
+        Filesystem::put('_posts/rc-test-post.md', '# Hello World!');
+
+        try {
+            $kernel = new HttpKernel();
+            $response = $kernel->handle(new Request());
+
+            $this->assertInstanceOf(Response::class, $response);
+            $this->assertNotInstanceOf(HtmlResponse::class, $response);
+            $this->assertSame(200, $response->statusCode);
+            $this->assertSame('OK', $response->statusMessage);
+
+            $headers = $this->getResponseHeaders($response);
+            $this->assertSame('application/xml', $headers['Content-Type']);
+
+            $this->assertStringStartsWith('<?xml version="1.0" encoding="UTF-8"?>', $response->body);
+            $this->assertStringContainsString('<rss', $response->body);
+        } finally {
+            Filesystem::unlink('_posts/rc-test-post.md');
+        }
+    }
+
     public function testGetContentTypeReturnsApplicationJsonForJsonOutputPath()
     {
         $page = $this->makePageWithOutputPath('foo.json');

From 8c25fca03c69f6ae5b6cff6f39e81c0552d1466a Mon Sep 17 00:00:00 2001
From: Emma De Silva <emma@desilva.se>
Date: Mon, 13 Jul 2026 21:20:47 +0200
Subject: [PATCH 5/6] Add release notes, upgrade guide, and epic notes for the
 RSS feed page conversion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 EPIC_NON_HTML_PAGES.md | 38 +++++++++++++++++++++++++++++++++-----
 HYDEPHP_V3_PLANNING.md |  5 +++--
 UPGRADE.md             | 26 +++++++++++++++-----------
 3 files changed, 51 insertions(+), 18 deletions(-)

diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md
index 0ae02f22ce2..83073f1d7df 100644
--- a/EPIC_NON_HTML_PAGES.md
+++ b/EPIC_NON_HTML_PAGES.md
@@ -137,6 +137,13 @@ versioned docs route keys like `docs/1.x/index` would false-positive
      > *(PR 5 part A: confirmed for `sitemap.xml` — within the allowlist. `feed.xml`,
      > `robots.txt`, and `llms.txt` are too, so the framework itself will not need
      > option (b); the remaining call in PR 8 is only for the power-user audience.)*
+     > *(PR 5 part B qualification: the RSS filename is user-configurable, and the old
+     > task wrote any `hyde.rss.filename` verbatim — so `RssFeedPage` overrides
+     > `identifierHasExplicitOutputExtension()` to always treat the configured filename
+     > as the literal output path, keeping `feed.rss` (or an extensionless name)
+     > working. This confirms the subclass override is a workable escape hatch for
+     > first-party pages, but does not settle option (b) for user-land `make()`
+     > callers, which remains the PR 8 call.)*
 
 ### D3: Sitemap inclusion becomes a page-level concern
 
@@ -244,7 +251,7 @@ container → fully custom page in code.
 > so the skip check cannot see its pages; instead the user page replaces the generated
 > one under the same collection key (`addPage()` keys by source path). Both are
 > asserted through the real `build` command output. The robots.txt equivalent remains
-> mandatory for PR 6.
+> mandatory for PR 6. *(Part B: both paths verified the same way for the feed page.)*
 
 ### D6: No built-in `TextPage` or `.txt` autodiscovery
 
@@ -388,14 +395,14 @@ Implementation notes (branch `v3/non-html-pages-sitemap-inclusion-policy`):
 - No UPGRADE.md entry: the fix requires no user action, and nothing realistic
   depended on search indexes appearing in sitemaps.
 
-### PR 5 — Convert sitemap and RSS from build tasks to pages 🚧 Part A (sitemap) implemented; part B (RSS) remaining
+### PR 5 — Convert sitemap and RSS from build tasks to pages ✅ Implemented
 
 Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed in
 `route:list`, included in the build manifest, overridable in user land.
 
-> **Split during implementation:** part A converts the sitemap, part B will convert
-> the RSS feed the same way. The bullets below still describe both; the part A notes
-> at the end of this section record what landed and what part B should mirror.
+> **Split during implementation:** part A converted the sitemap, part B converted
+> the RSS feed the same way. The bullets below describe both; the notes at the end
+> of this section record what landed in each part.
 
 - Register `sitemap.xml` / `feed.xml` as `InMemoryPage`s per D4, with a lazy
   `compile` that resolves the generator from the container
@@ -458,6 +465,27 @@ Implementation notes, part A (branch `v3/non-html-pages-convert-sitemap`):
   so removing the RSS task won't churn them again. Part B should mirror everything
   here with `RssFeedPage`, taking its route key from `RssFeedGenerator::getFilename()`.
 
+Implementation notes, part B (branch `v3/non-html-pages-convert-rss-feed`):
+
+- `RssFeedPage` mirrors `SitemapPage` throughout: thin subclass in `XmlGenerators`,
+  container-resolved `compile()` (rebind verified by test), registered behind
+  `Features::hasRss()` with the D5 skip check, hidden from navigation, D3-excluded
+  from the sitemap, and both user override paths verified end-to-end.
+- One divergence: the route key comes from `RssFeedGenerator::getFilename()`
+  (config `hyde.rss.filename`), and since the removed task wrote any configured
+  filename verbatim, `RssFeedPage` overrides `identifierHasExplicitOutputExtension()`
+  to always use the filename as the literal output path — `feed.rss` or an
+  extensionless name would otherwise regress to `.html`-suffixed output (see the
+  D2 part B qualification).
+- `build:rss` keeps the old task's semantics of having no guard at all: invoked
+  explicitly it generates the feed regardless of the feature conditions (no site
+  URL, no posts, or `hyde.rss.enabled` false), falling back to `new RssFeedPage()`
+  when the route is not registered. Only `build:sitemap` has a base-URL guard,
+  matching the tasks each command replaced.
+- `BuildTaskService` no longer registers any feature-gated tasks; the `Features`
+  facade import went with the last one. The remaining framework tasks
+  (clean/transfer/manifest) are all config-gated.
+
 ### PR 6 — Generated `robots.txt`
 
 Goal: sensible robots.txt out of the box, zero config.
diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md
index 8f8cbb75f4b..20534efe5b5 100644
--- a/HYDEPHP_V3_PLANNING.md
+++ b/HYDEPHP_V3_PLANNING.md
@@ -25,7 +25,7 @@ Having this document in code lets us know the devlopment state at any given poin
 - Added Blade Blocks for rendering Blade and Blade components from fenced code blocks in Markdown pages. The supported directives are `blade render` and `blade component(name)`, and the feature is controlled by `markdown.enable_blade`. ([#2504](https://github.com/hydephp/develop/pull/2504))
 - Pages can now compile to non-HTML output files. Page classes declare their output file extension through the new static `$outputExtension` property (defaulting to `.html`), and in-memory page identifiers can declare a `.json`, `.txt`, or `.xml` extension directly, so `InMemoryPage::make('robots.txt', contents: ...)` compiles to `_site/robots.txt` through the standard site build. Only the HTML extension is implicit in route keys: pages compiled to non-HTML files keep their extension in the route key, formalizing the convention already used by the documentation search index.
 - Pages can now control their own sitemap inclusion. Set `sitemap: false` in a page's front matter to exclude it from the generated `sitemap.xml`, or override the new `HydePage::showInSitemap()` method in custom page classes. Pages compiled to non-HTML output files (like `robots.txt`) are excluded by default, and `sitemap: true` front matter opts such a page back in.
-- The sitemap is now a first-class page instead of a post-build side effect: when sitemap generation is enabled, `sitemap.xml` is registered as a route, so it is served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` class in the service container, and registering a user-defined page with the `sitemap.xml` route key (from a service provider, booting callback, or extension) replaces the generated page entirely.
+- The sitemap and RSS feed are now first-class pages instead of post-build side effects: when the respective feature is enabled, `sitemap.xml` and the RSS feed (`feed.xml`, or the configured `hyde.rss.filename`) are registered as routes, so they are served by `hyde serve`, listed in `route:list`, included in the build manifest, and compiled through the standard site build. The output can be customized by rebinding the `SitemapGenerator` or `RssFeedGenerator` class in the service container, and registering a user-defined page with the same route key (from a service provider, booting callback, or extension) replaces the generated page entirely.
 
 ### Feature Changes
 
@@ -48,6 +48,7 @@ Having this document in code lets us know the devlopment state at any given poin
 - In-memory page identifiers ending in `.json`, `.txt`, or `.xml` now compile to that path as-is instead of gaining a second `.html` extension. The old double-extension outputs (like `data.json.html`) were almost certainly never intended, so no real sites are expected to be affected.
 - Redirect source paths declared in `hyde.redirects` ending in `.json`, `.txt`, or `.xml` are now rejected with an exception, since a meta refresh redirect cannot work for files served as non-HTML content. Previously such entries silently produced an unreachable `legacy.json.html` file, so no working configuration is affected.
 - Removed the `GenerateSitemap` post-build task, as the sitemap is now generated through the page and route system. Sites that just enable or disable the sitemap through configuration are unaffected. Code referencing the task class — like a user-land `GenerateSitemap` build task relying on the same-basename override mechanism to replace the framework task — should register a custom `sitemap.xml` page or rebind `SitemapGenerator` in the container instead. The `build:sitemap` command now compiles the registered page, and reports failure with exit code 1 instead of 3 when no base URL is configured.
+- Removed the `GenerateRssFeed` post-build task, as the RSS feed is now generated through the page and route system. Sites that just enable or disable the feed through configuration are unaffected. Code referencing the task class — like a user-land `GenerateRssFeed` build task relying on the same-basename override mechanism to replace the framework task — should register a custom page with the configured feed route key or rebind `RssFeedGenerator` in the container instead. The `build:rss` command now compiles the registered page, and still generates the feed regardless of the feature conditions when invoked explicitly.
 - Removed `Redirect::create()`, `Redirect::store()`, and the `Redirect` constructor's `showText` argument. Redirects must now be declared in `hyde.redirects`, keeping all generated output inside the kernel-owned build graph. Redirect routes are intrinsically excluded from navigation menus and sitemaps, and always include an accessible fallback link.
 
 - Removed the `rebuild` command (`RebuildPageCommand`). It was originally added to build a single file to disk before the realtime compiler existed, and later used internally by the RC to build-and-serve a path, but the RC now renders everything in-memory, leaving `rebuild` with no remaining consumer. It also had no safe user-facing use case: a single-page build only produces a correct `_site` when the page is self-contained, while a page change routinely invalidates aggregate outputs (sitemap, RSS, search index, post listings, navigation), so single-path building could silently leave a stale output directory that looked complete. The underlying single-page build capability remains available internally via the `StaticPageBuilder` action. ([#2490](https://github.com/hydephp/develop/pull/2490))
@@ -61,7 +62,7 @@ Please fill in UPGRADE.md as you make changes.
 - The `rebuild` command has been removed. If you need to build a single page programmatically, use `Hyde\Framework\Actions\StaticPageBuilder::handle()` instead.
 - Move any calls to `Redirect::create()` or `Redirect::store()` into the `redirects` array in `config/hyde.php`, using the old path as the key and the destination as the value.
 - Rename `$fileExtension` to `$sourceExtension` in custom page classes, and update any calls to `fileExtension()` or `setFileExtension()` to `sourceExtension()` and `setSourceExtension()`.
-- If you referenced the removed `GenerateSitemap` build task class (for example to override it with a same-basename user-land task), customize the sitemap by rebinding `SitemapGenerator` in the service container or by registering your own `sitemap.xml` page instead.
+- If you referenced the removed `GenerateSitemap` or `GenerateRssFeed` build task classes (for example to override one with a same-basename user-land task), customize the output by rebinding `SitemapGenerator` or `RssFeedGenerator` in the service container or by registering your own page with the same route key instead.
 
 ---
 
diff --git a/UPGRADE.md b/UPGRADE.md
index 377231b334b..edf5e694f0c 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -166,14 +166,16 @@ Configured redirects are included in `route:list` and generated by `php hyde bui
 from navigation menus and the sitemap. Redirect pages always include a visible fallback link, so the previous
 `showText` constructor argument is no longer available.
 
-## Step 5: Review Sitemap Customizations
+## Step 5: Review Sitemap and RSS Feed Customizations
 
-The sitemap is now generated as a regular page instead of by a post-build task, so `sitemap.xml` is served by
-`php hyde serve`, listed in `route:list`, and included in the build manifest. Sites that just enable or disable
-the sitemap through `hyde.generate_sitemap` and `hyde.url` need no changes.
+The sitemap and RSS feed are now generated as regular pages instead of by post-build tasks, so `sitemap.xml` and
+the RSS feed (`feed.xml`, or your configured `hyde.rss.filename`) are served by `php hyde serve`, listed in
+`route:list`, and included in the build manifest. Sites that just enable or disable these features through
+`hyde.generate_sitemap`, `hyde.rss`, and `hyde.url` need no changes.
 
-The `GenerateSitemap` post-build task class has been removed. If you overrode it with a same-basename build task,
-or referenced the class directly, customize the sitemap through one of its replacement tiers instead:
+The `GenerateSitemap` and `GenerateRssFeed` post-build task classes have been removed. If you overrode one with a
+same-basename build task, or referenced the classes directly, customize the output through one of the replacement
+tiers instead:
 
 - Rebind the generator in the service container to change the output while keeping the page registration:
 
@@ -183,8 +185,10 @@ use Hyde\Framework\Features\XmlGenerators\SitemapGenerator;
 app()->bind(SitemapGenerator::class, MyCustomSitemapGenerator::class);
 ```
 
-- Or register your own page with the `sitemap.xml` route key (from a service provider, booting callback, or
-  extension), which replaces the generated page entirely:
+The same works for `RssFeedGenerator`.
+
+- Or register your own page with the same route key (`sitemap.xml`, or the configured feed filename) from a
+  service provider, booting callback, or extension, which replaces the generated page entirely:
 
 ```php
 use Hyde\Hyde;
@@ -195,8 +199,8 @@ Hyde::kernel()->booting(function ($kernel): void {
 });
 ```
 
-The `build:sitemap` command still works and now compiles the registered page. When no base URL is configured it
-reports failure with exit code 1 instead of 3.
+The `build:sitemap` and `build:rss` commands still work and now compile the registered pages. When no base URL is
+configured, `build:sitemap` reports failure with exit code 1 instead of 3.
 
 ## Step 6: Rename Page File Extension References
 
@@ -246,7 +250,7 @@ Use this checklist to track your upgrade progress:
 - [ ] Reviewed `markdown.allow_html` and `markdown.enable_blade` and explicitly selected the appropriate trust policy
 - [ ] Replaced any `php hyde rebuild <path>` usage with `StaticPageBuilder::handle()` or a full `php hyde build`
 - [ ] Moved calls to `Redirect::create()` or `Redirect::store()` into the `hyde.redirects` configuration array
-- [ ] Replaced any references to the removed `GenerateSitemap` build task with a `SitemapGenerator` container rebind or a user-defined `sitemap.xml` page
+- [ ] Replaced any references to the removed `GenerateSitemap` and `GenerateRssFeed` build tasks with a generator container rebind or a user-defined page
 - [ ] Renamed `$fileExtension`, `fileExtension()`, and `setFileExtension()` to `$sourceExtension`, `sourceExtension()`, and `setSourceExtension()` in custom page classes and call sites
 
 ## Troubleshooting

From 8199923b3bb286481d214e6c792404474ed621ca Mon Sep 17 00:00:00 2001
From: Emma De Silva <emma@desilva.se>
Date: Mon, 13 Jul 2026 21:22:48 +0200
Subject: [PATCH 6/6] Disable RSS feed generation in tests asserting exact
 default collections

These tests create posts while the monorepo test environment configures a
site URL, so the feed page would now appear in their exact collection
assertions. The feed route registration itself is covered by RssFeedPageTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 packages/framework/tests/Feature/PageCollectionTest.php  | 2 +-
 packages/framework/tests/Feature/RouteCollectionTest.php | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/packages/framework/tests/Feature/PageCollectionTest.php b/packages/framework/tests/Feature/PageCollectionTest.php
index d1dfdae3b97..eb21042990f 100644
--- a/packages/framework/tests/Feature/PageCollectionTest.php
+++ b/packages/framework/tests/Feature/PageCollectionTest.php
@@ -26,7 +26,7 @@ protected function setUp(): void
     {
         parent::setUp();
 
-        config(['hyde.generate_sitemap' => false]);
+        config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false]);
     }
 
     public function testBootMethodCreatesNewPageCollectionAndDiscoversPagesAutomatically()
diff --git a/packages/framework/tests/Feature/RouteCollectionTest.php b/packages/framework/tests/Feature/RouteCollectionTest.php
index b3422edbf9b..b069f2f5b29 100644
--- a/packages/framework/tests/Feature/RouteCollectionTest.php
+++ b/packages/framework/tests/Feature/RouteCollectionTest.php
@@ -26,7 +26,7 @@ protected function setUp(): void
     {
         parent::setUp();
 
-        config(['hyde.generate_sitemap' => false]);
+        config(['hyde.generate_sitemap' => false, 'hyde.rss.enabled' => false]);
     }
 
     public function testBootMethodDiscoversAllPages()
