From f43722cd74697f94275dbc265ffd0541edf6f03b Mon Sep 17 00:00:00 2001
From: Emma De Silva <emma@desilva.se>
Date: Wed, 15 Jul 2026 00:02:19 +0200
Subject: [PATCH 1/5] Remove overengineered subclasses

---
 EPIC_NON_HTML_PAGES.md                        | 82 ++++++++-----------
 .../Console/Commands/BuildRssFeedCommand.php  |  4 +-
 .../Console/Commands/BuildSitemapCommand.php  |  3 +-
 .../src/Foundation/HydeCoreExtension.php      | 40 ++++++---
 .../TextGenerators/LlmsTxtGenerator.php       |  1 -
 .../Features/TextGenerators/LlmsTxtPage.php   | 39 ---------
 .../TextGenerators/RobotsTxtGenerator.php     |  5 +-
 .../Features/TextGenerators/RobotsTxtPage.php | 39 ---------
 .../Features/XmlGenerators/RssFeedPage.php    | 39 ---------
 .../Features/XmlGenerators/SitemapPage.php    | 39 ---------
 .../Commands/BuildRssFeedCommandTest.php      |  1 -
 .../Commands/BuildSitemapCommandTest.php      |  1 -
 .../tests/Feature/LlmsTxtPageTest.php         | 12 ++-
 .../tests/Feature/RobotsTxtPageTest.php       | 12 ++-
 .../tests/Feature/RssFeedPageTest.php         | 14 ++--
 .../tests/Feature/SitemapFeatureTest.php      |  1 -
 .../tests/Feature/SitemapPageTest.php         | 14 ++--
 17 files changed, 87 insertions(+), 259 deletions(-)
 delete mode 100644 packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php
 delete mode 100644 packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php
 delete mode 100644 packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php
 delete mode 100644 packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php

diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md
index 6097e3dca58..c4aa9afd80f 100644
--- a/EPIC_NON_HTML_PAGES.md
+++ b/EPIC_NON_HTML_PAGES.md
@@ -117,8 +117,8 @@ a standalone feature in its own right.
 > **Implementation constraint (from PR 1) — read before writing `showInSitemap()`:**
 > the "output is not `.html`" default MUST be derived from the page's *resolved
 > output path* (`getOutputPath()`), not merely from the declared extension. The
-> resolved path is the canonical answer and also covers specialized classes such as
-> `RssFeedPage`, whose configurable filename cannot be represented by one fixed
+> resolved path is the canonical answer and also covers generated pages such as
+> the RSS feed, whose configurable filename cannot be represented by one fixed
 > extension. Keying the default off `getOutputPath()` makes all generated pages
 > self-exclude correctly and prevents the `search.json` leak from returning.
 
@@ -188,30 +188,17 @@ Registration happens in `HydeCoreExtension::discoverPages()` behind the existing
 `Features::hasSitemap()` / `Features::hasRss()` conditions, replacing the
 registrations in `BuildTaskService::registerFrameworkTasks()`.
 
-**Plain page vs. thin subclass is deferred to PR 5.** D3 already defaults non-HTML
-pages out of the sitemap, so a subclass is *not* needed merely to stop a generated
-page self-listing. The only remaining reasons to introduce `SitemapPage extends
-InMemoryPage` (mirroring `DocumentationSearchIndex`) are type identity (`instanceof`)
-and giving the `build:sitemap` / `build:rss` commands a concrete class to
-instantiate. Lean toward a plain `InMemoryPage` registered with a container-bound
-`compile` closure unless PR 5 surfaces a concrete need for the subclass.
-
-> **Decided (PR 5 part A): thin subclass.** The command wiring is the concrete need
-> the deferral anticipated: `build:sitemap` builds the registered route's page and
-> must fall back to a fresh instance when the route is not registered (mirroring how
-> `BuildSearchCommand` falls back to `new DocumentationSearchIndex()`), and a plain
-> page would need that construction logic exported from a shared factory anyway.
-> `SitemapPage extends InMemoryPage` lives next to its generator in
-> `Hyde\Framework\Features\XmlGenerators`, keeps the construction in one place, and
-> stays out of `Hyde\Pages` so it is exempt from the discovered-page unit test
-> contract, exactly like `DocumentationSearchIndex`. The D4 swappability tier is
-> unaffected: `compile()` resolves `SitemapGenerator` from the container, verified
-> by a rebind test. Part B should mirror this with `RssFeedPage`.
+**Decided: plain `InMemoryPage`.** D3 already defaults non-HTML pages out of the
+sitemap, and the commands only build registered routes. The temporary thin subclasses
+were justified by an early command fallback that instantiated fresh pages, but that
+fallback was removed in review. With no type-identity consumer remaining, generated
+pages are ordinary `InMemoryPage` instances registered with container-resolved
+`compile` macros. The D4 swappability tier is preserved and verified by rebind tests.
 
 ### D5: User-defined pages beat generators
 
 If the page collection already contains a user-defined page with a route key such
-as `robots.txt`, the framework does not register its generated `RobotsTxtPage`.
+as `robots.txt`, the framework does not register its generated page.
 This follows the pattern of `discoverDocumentationRootRedirect()`, which skips when
 a user-defined route exists.
 Users can register an `InMemoryPage` from a service provider or provide a custom page
@@ -389,9 +376,8 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed
   (`app(SitemapGenerator::class)->generate()` / `app(RssFeedGenerator::class)->generate()`),
   so the implementation is swappable via container rebind. RSS route key comes from
   `RssFeedGenerator::getFilename()` (config `hyde.rss.filename`).
-- **Decide plain `InMemoryPage` (compile macro) vs. thin subclass here** (D4). Default
-  to plain + container binding unless type identity or command wiring forces a
-  subclass; note that D3 already handles sitemap self-exclusion either way.
+- Use plain `InMemoryPage` instances with container-bound `compile` macros (D4);
+  D3 already handles sitemap self-exclusion.
 - **Verify the generators are actually container-resolvable** before advertising the
   rebind: no unresolvable constructor dependencies, not `final` (or the swap can't be
   bound). D4's whole swappability tier is a lie if `app(SitemapGenerator::class)`
@@ -404,8 +390,7 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed
   `BuildTaskService::registerFrameworkTasks()` (evaluate deprecation vs. removal —
   v3 allows breaking changes, but third-party code may reference the task classes).
 - Rewire `build:sitemap` / `build:rss` commands to build the same registered page
-  via `StaticPageBuilder::handle(...)` (a shared factory if the pages are plain
-  `InMemoryPage`s, or `new …Page()` if subclassed).
+  via `StaticPageBuilder::handle(...)`.
 - Verify `GlobalMetadataBag` head links and the `hyde.url` requirements still hold
   (`Features::hasSitemap()` already requires a site URL).
 - Nice side effect: build output shows them under "Dynamic Pages" with the standard
@@ -413,10 +398,9 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed
 
 Implementation notes, part A (branch `v3/non-html-pages-convert-sitemap`):
 
-- `SitemapPage extends InMemoryPage` per the D4 "thin subclass" decision (see the D4
-  note for the rationale), hiding itself from navigation like
-  `DocumentationSearchIndex` and self-excluding from the sitemap via the D3 non-HTML
-  default. Registered at the end of `HydeCoreExtension::discoverPages()` behind
+- A plain `InMemoryPage` with a container-resolved `compile` macro is hidden from
+  navigation and self-excludes from the sitemap via the D3 non-HTML default.
+  Registered at the end of `HydeCoreExtension::discoverPages()` behind
   `Features::hasSitemap()` with the D5 skip check (see the D5 note for the verified
   override ordering semantics).
 - `SitemapGenerator` verified container-resolvable and rebindable: not `final`, no
@@ -454,15 +438,15 @@ Implementation notes, part A (branch `v3/non-html-pages-convert-sitemap`):
   `Features::hasSitemap()` condition that registers the page — no drift possible.
 - Realtime compiler needed no changes (PR 2's route-first resolution); a serve test
   asserts `sitemap.xml` returns the generated XML with `application/xml`.
-- Heads-up for part B: `BuildTaskServiceUnitTest`'s framework-task fixtures were
+- For part B, `BuildTaskServiceUnitTest`'s framework-task fixtures were
   migrated from `GenerateSitemap` to `GenerateBuildManifest` (not `GenerateRssFeed`)
-  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()`.
+  so removing the RSS task would not churn them again. The RSS route key comes 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
+- The RSS feed mirrors the sitemap throughout: a plain `InMemoryPage` with a
+  container-resolved `compile` macro (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()`
@@ -489,17 +473,15 @@ Goal: sensible robots.txt out of the box, zero config.
 
 Implementation notes (branch `v3/non-html-pages-robots`):
 
-- `RobotsTxtPage extends InMemoryPage` mirrors `SitemapPage`/`RssFeedPage` throughout:
-  thin subclass, container-resolved generator in `compile()` (rebind verified by test),
+- The generated robots.txt is a plain `InMemoryPage` with a container-resolved
+  `compile` macro (rebind verified by test),
   registered in `HydeCoreExtension::discoverPages()` with the D5 skip check, hidden
   from navigation, D3-excluded from the sitemap via the non-HTML default, and both
   user override paths (booting callback and extension) verified end-to-end through
   the real `build` command per the D5 mandate.
-- The page and its `RobotsTxtGenerator` live in a new
-  `Hyde\Framework\Features\TextGenerators` namespace mirroring `XmlGenerators`
-  (and likewise outside `Hyde\Pages`, exempting the page from the discovered-page
-  unit test contract). PR 7's llms.txt generator and page should land there too —
-  as `LlmsTxtGenerator`/`LlmsTxtPage` for symmetry, superseding the epic's earlier
+- `RobotsTxtGenerator` lives in the
+  `Hyde\Framework\Features\TextGenerators` namespace mirroring `XmlGenerators`.
+  PR 7's generator follows it as `LlmsTxtGenerator`, superseding the epic's earlier
   `GeneratesLlmsTxt` working name.
 - Feature gate: `Features::hasRobotsTxt()` reads only `hyde.robots.enabled`
   (default `true`). Unlike `hasSitemap()`/`hasRss()` there is no site URL
@@ -523,7 +505,7 @@ Implementation notes (branch `v3/non-html-pages-robots`):
   index, instead of surfacing as a PHP-level type error at build time. Later
   generated text pages copying this pattern (llms.txt) should keep both halves —
   verbatim strings, explicit validation.
-- `RobotsTxtPage` declares its `.txt` output extension per D2.
+- Its `InMemoryPage` identifier includes the `.txt` output extension per D2.
 - No `build:robots` command: the sitemap/RSS commands exist only as carry-overs of
   the removed post-build tasks; robots.txt never had one, and the standard build
   and realtime compiler (serve test asserts `text/plain`) cover the lifecycle.
@@ -547,10 +529,10 @@ Goal: best-in-class llms.txt support — no other SSG generates this well out of
 
 Implementation notes (branch `v3/non-html-pages-llms-txt`):
 
-- `LlmsTxtPage` + `LlmsTxtGenerator` land in `Hyde\Framework\Features\TextGenerators`
-  next to the robots.txt pair (superseding the `GeneratesLlmsTxt` working name, as
-  PR 6 anticipated), and mirror `RobotsTxtPage` throughout: thin `InMemoryPage`
-  subclass, container-resolved generator in `compile()` (rebind verified by test),
+- `LlmsTxtGenerator` lands in `Hyde\Framework\Features\TextGenerators`
+  next to the robots.txt generator (superseding the `GeneratesLlmsTxt` working name,
+  as PR 6 anticipated), and its plain `InMemoryPage` uses a container-resolved
+  `compile` macro (rebind verified by test),
   registered in `HydeCoreExtension::discoverPages()` with the D5 skip check, hidden
   from navigation, D3-excluded from the sitemap, and both user override paths verified
   end-to-end through the real `build` command. No `build:llms` command, for the same
@@ -646,8 +628,8 @@ Implementation notes (branch `v3/non-html-pages-llms-txt`):
   generator-level curation concern rather than a page-level default (the sitemap
   precedent likewise keeps its 404 handling in the generator), and it is the reason the
   sitemap-derived inclusion rule is not a bare alias for `showInSitemap()`.
-- Everything else the epic left implicit held: `LlmsTxtPage` declares its `.txt`
-  output extension, and the generated page self-excludes from its own listing (and the sitemap)
+- Everything else the epic left implicit held: the `llms.txt` page identifier declares
+  its `.txt` output extension, and the generated page self-excludes from its own listing (and the sitemap)
   through the D3 resolved-output-path default.
 
 > **Scope correction (post-implementation review).** The first cut of this PR was
diff --git a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php
index 85a9b820f3a..29cfda584f4 100644
--- a/packages/framework/src/Console/Commands/BuildRssFeedCommand.php
+++ b/packages/framework/src/Console/Commands/BuildRssFeedCommand.php
@@ -8,7 +8,7 @@
 use Hyde\Console\Concerns\Command;
 use Hyde\Foundation\Facades\Routes;
 use Hyde\Framework\Actions\StaticPageBuilder;
-use Hyde\Framework\Features\XmlGenerators\RssFeedPage;
+use Hyde\Framework\Features\XmlGenerators\RssFeedGenerator;
 
 use function sprintf;
 
@@ -25,7 +25,7 @@ class BuildRssFeedCommand extends Command
 
     public function handle(): int
     {
-        $page = Routes::find(RssFeedPage::routeKey())?->getPage();
+        $page = Routes::find(RssFeedGenerator::getFilename())?->getPage();
 
         if ($page === null) {
             $this->error('Cannot generate the RSS feed as the feature is not enabled');
diff --git a/packages/framework/src/Console/Commands/BuildSitemapCommand.php b/packages/framework/src/Console/Commands/BuildSitemapCommand.php
index 6f503d210a9..b1c8abc6afc 100644
--- a/packages/framework/src/Console/Commands/BuildSitemapCommand.php
+++ b/packages/framework/src/Console/Commands/BuildSitemapCommand.php
@@ -8,7 +8,6 @@
 use Hyde\Console\Concerns\Command;
 use Hyde\Foundation\Facades\Routes;
 use Hyde\Framework\Actions\StaticPageBuilder;
-use Hyde\Framework\Features\XmlGenerators\SitemapPage;
 
 use function sprintf;
 
@@ -25,7 +24,7 @@ class BuildSitemapCommand extends Command
 
     public function handle(): int
     {
-        $page = Routes::find(SitemapPage::routeKey())?->getPage();
+        $page = Routes::find('sitemap.xml')?->getPage();
 
         if ($page === null) {
             $this->error('Cannot generate the sitemap as the feature is not enabled');
diff --git a/packages/framework/src/Foundation/HydeCoreExtension.php b/packages/framework/src/Foundation/HydeCoreExtension.php
index 85ab57ee089..d03aa4c8f55 100644
--- a/packages/framework/src/Foundation/HydeCoreExtension.php
+++ b/packages/framework/src/Foundation/HydeCoreExtension.php
@@ -10,6 +10,7 @@
 use Hyde\Pages\MarkdownPage;
 use Hyde\Pages\MarkdownPost;
 use Hyde\Pages\DocumentationPage;
+use Hyde\Pages\InMemoryPage;
 use Hyde\Pages\Concerns\HydePage;
 use Hyde\Support\BuildWarnings;
 use Hyde\Support\Models\Redirect;
@@ -21,14 +22,15 @@
 use Hyde\Facades\Config;
 use Hyde\Framework\Features\Documentation\DocumentationSearchPage;
 use Hyde\Framework\Features\Documentation\DocumentationSearchIndex;
-use Hyde\Framework\Features\TextGenerators\LlmsTxtPage;
-use Hyde\Framework\Features\TextGenerators\RobotsTxtPage;
-use Hyde\Framework\Features\XmlGenerators\RssFeedPage;
-use Hyde\Framework\Features\XmlGenerators\SitemapPage;
+use Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator;
+use Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator;
+use Hyde\Framework\Features\XmlGenerators\RssFeedGenerator;
+use Hyde\Framework\Features\XmlGenerators\SitemapGenerator;
 use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersion;
 use Hyde\Framework\Features\Documentation\Versioning\DocumentationVersions;
 
 use function Hyde\unslash;
+use function app;
 use function array_filter;
 use function array_keys;
 use function sprintf;
@@ -105,32 +107,46 @@ public function discoverPages(PageCollection $collection): void
     /** Add the generated sitemap page unless the route is user-defined. */
     protected function discoverSitemapPage(PageCollection $collection): void
     {
-        if (! $this->hasPageWithRouteKey($collection, SitemapPage::routeKey())) {
-            $collection->addPage(new SitemapPage());
+        if (! $this->hasPageWithRouteKey($collection, 'sitemap.xml')) {
+            $page = new InMemoryPage('sitemap.xml', ['navigation' => ['hidden' => true]]);
+            $page->macro('compile', fn (): string => app(SitemapGenerator::class)->generate()->getXml());
+
+            $collection->addPage($page);
         }
     }
 
     /** 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());
+        $routeKey = RssFeedGenerator::getFilename();
+
+        if (! $this->hasPageWithRouteKey($collection, $routeKey)) {
+            $page = new InMemoryPage($routeKey, ['navigation' => ['hidden' => true]]);
+            $page->macro('compile', fn (): string => app(RssFeedGenerator::class)->generate()->getXml());
+
+            $collection->addPage($page);
         }
     }
 
     /** Add the generated robots.txt page unless the route is user-defined. */
     protected function discoverRobotsTxtPage(PageCollection $collection): void
     {
-        if (! $this->hasPageWithRouteKey($collection, RobotsTxtPage::routeKey())) {
-            $collection->addPage(new RobotsTxtPage());
+        if (! $this->hasPageWithRouteKey($collection, 'robots.txt')) {
+            $page = new InMemoryPage('robots.txt', ['navigation' => ['hidden' => true]]);
+            $page->macro('compile', fn (): string => app(RobotsTxtGenerator::class)->generate());
+
+            $collection->addPage($page);
         }
     }
 
     /** Add the generated llms.txt page unless the route is user-defined. */
     protected function discoverLlmsTxtPage(PageCollection $collection): void
     {
-        if (! $this->hasPageWithRouteKey($collection, LlmsTxtPage::routeKey())) {
-            $collection->addPage(new LlmsTxtPage());
+        if (! $this->hasPageWithRouteKey($collection, 'llms.txt')) {
+            $page = new InMemoryPage('llms.txt', ['navigation' => ['hidden' => true]]);
+            $page->macro('compile', fn (): string => app(LlmsTxtGenerator::class)->generate());
+
+            $collection->addPage($page);
         }
     }
 
diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php
index 96fb4fc3e8e..ffc70f80f16 100644
--- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php
+++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtGenerator.php
@@ -46,7 +46,6 @@
  * of the generated file may change in future minor and patch releases to follow the spec.
  *
  * @see https://llmstxt.org/
- * @see \Hyde\Framework\Features\TextGenerators\LlmsTxtPage
  */
 class LlmsTxtGenerator
 {
diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php
deleted file mode 100644
index 9d10ec260f9..00000000000
--- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Hyde\Framework\Features\TextGenerators;
-
-use Hyde\Pages\InMemoryPage;
-
-use function app;
-
-/**
- * @internal This page is used to render the llms.txt file for the site.
- *
- * The contents are generated by the LlmsTxtGenerator, 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 LlmsTxtPage extends InMemoryPage
-{
-    public function __construct()
-    {
-        parent::__construct(static::routeKey(), [
-            'navigation' => ['hidden' => true],
-        ]);
-    }
-
-    public function compile(): string
-    {
-        return app(LlmsTxtGenerator::class)->generate();
-    }
-
-    /**
-     * Get the route key of the llms.txt file, which for this page is also its output path.
-     */
-    public static function routeKey(): string
-    {
-        return 'llms.txt';
-    }
-}
diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php
index fd73dcb512f..0cb769462ab 100644
--- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php
+++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php
@@ -8,7 +8,6 @@
 use Hyde\Facades\Config;
 use Hyde\Facades\Features;
 use Hyde\Framework\Exceptions\InvalidConfigurationException;
-use Hyde\Framework\Features\XmlGenerators\SitemapPage;
 
 use function array_merge;
 use function get_debug_type;
@@ -23,8 +22,6 @@
  * is written verbatim as a Disallow rule, so wildcard patterns and the empty
  * rule are supported. A link to the sitemap is included when the sitemap
  * feature is enabled.
- *
- * @see \Hyde\Framework\Features\TextGenerators\RobotsTxtPage
  */
 class RobotsTxtGenerator
 {
@@ -39,7 +36,7 @@ protected function getLines(): array
         $lines = array_merge(['User-agent: *'], $this->getRuleLines());
 
         if (Features::hasSitemap()) {
-            $lines = array_merge($lines, ['', 'Sitemap: '.Hyde::url(SitemapPage::routeKey())]);
+            $lines = array_merge($lines, ['', 'Sitemap: '.Hyde::url('sitemap.xml')]);
         }
 
         return $lines;
diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php
deleted file mode 100644
index 62653a35f5c..00000000000
--- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Hyde\Framework\Features\TextGenerators;
-
-use Hyde\Pages\InMemoryPage;
-
-use function app;
-
-/**
- * @internal This page is used to render the robots.txt file for the site.
- *
- * The contents are generated by the RobotsTxtGenerator, 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 RobotsTxtPage extends InMemoryPage
-{
-    public function __construct()
-    {
-        parent::__construct(static::routeKey(), [
-            'navigation' => ['hidden' => true],
-        ]);
-    }
-
-    public function compile(): string
-    {
-        return app(RobotsTxtGenerator::class)->generate();
-    }
-
-    /**
-     * Get the route key of the robots.txt file, which for this page is also its output path.
-     */
-    public static function routeKey(): string
-    {
-        return 'robots.txt';
-    }
-}
diff --git a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php
deleted file mode 100644
index 32d2315d936..00000000000
--- a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?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();
-    }
-}
diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php
deleted file mode 100644
index 075e79cac23..00000000000
--- a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?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 sitemap for the site.
- *
- * The contents are generated by the SitemapGenerator, 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 SitemapPage extends InMemoryPage
-{
-    public function __construct()
-    {
-        parent::__construct(static::routeKey(), [
-            'navigation' => ['hidden' => true],
-        ]);
-    }
-
-    public function compile(): string
-    {
-        return app(SitemapGenerator::class)->generate()->getXml();
-    }
-
-    /**
-     * Get the route key of the sitemap, which for this page is also its output path.
-     */
-    public static function routeKey(): string
-    {
-        return 'sitemap.xml';
-    }
-}
diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php
index 6c567804a7c..21547b40dd9 100644
--- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php
+++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php
@@ -11,7 +11,6 @@
 use Hyde\Testing\TestCase;
 
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildRssFeedCommand::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/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php
index 940f9342b04..02d8358654f 100644
--- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php
+++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php
@@ -10,7 +10,6 @@
 use Hyde\Testing\TestCase;
 
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSitemapCommand::class)]
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapPage::class)]
 class BuildSitemapCommandTest extends TestCase
 {
     public function testSitemapIsGeneratedWhenConditionsAreMet()
diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php
index faa3529dfe3..f1f58c32ec3 100644
--- a/packages/framework/tests/Feature/LlmsTxtPageTest.php
+++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php
@@ -12,7 +12,6 @@
 use Hyde\Foundation\Concerns\HydeExtension;
 use Hyde\Foundation\Kernel\PageCollection;
 use Hyde\Framework\Features\TextGenerators\LlmsTxtGenerator;
-use Hyde\Framework\Features\TextGenerators\LlmsTxtPage;
 use Illuminate\Support\Facades\File;
 
 /**
@@ -22,7 +21,6 @@
  *
  * @see \Hyde\Framework\Testing\Feature\LlmsTxtGeneratorTest
  */
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\TextGenerators\LlmsTxtPage::class)]
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)]
 class LlmsTxtPageTest extends TestCase
 {
@@ -46,7 +44,7 @@ public function testLlmsTxtPageIsRegisteredAsRouteByDefault()
 
         $page = Routes::get('llms.txt')->getPage();
 
-        $this->assertInstanceOf(LlmsTxtPage::class, $page);
+        $this->assertSame(InMemoryPage::class, $page::class);
         $this->assertSame('llms.txt', $page->getOutputPath());
         $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath());
         $this->assertSame('llms.txt', $page->getRouteKey());
@@ -68,7 +66,7 @@ public function testLlmsTxtPageIsNotRegisteredWhenDisabledInConfig()
 
     public function testLlmsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap()
     {
-        $page = new LlmsTxtPage();
+        $page = Routes::get('llms.txt')->getPage();
 
         $this->assertFalse($page->showInNavigation());
         $this->assertFalse($page->showInSitemap());
@@ -76,7 +74,7 @@ public function testLlmsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap()
 
     public function testLlmsTxtPageCompilesUsingTheLlmsTxtGenerator()
     {
-        $this->assertSame((new LlmsTxtGenerator())->generate(), (new LlmsTxtPage())->compile());
+        $this->assertSame((new LlmsTxtGenerator())->generate(), Routes::get('llms.txt')->getPage()->compile());
     }
 
     public function testLlmsTxtGeneratorCanBeSwappedThroughTheServiceContainer()
@@ -134,7 +132,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedLlm
 
         $page = Routes::get('llms.txt')->getPage();
 
-        $this->assertNotInstanceOf(LlmsTxtPage::class, $page);
+        $this->assertSame('user defined llms', $page->compile());
         $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'llms.txt')->count());
 
         $this->artisan('build')->assertExitCode(0);
@@ -148,7 +146,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedLlms
 
         $page = Routes::get('llms.txt')->getPage();
 
-        $this->assertNotInstanceOf(LlmsTxtPage::class, $page);
+        $this->assertSame('extension defined llms', $page->compile());
         $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'llms.txt')->count());
 
         $this->artisan('build')->assertExitCode(0);
diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php
index 3cde1aa6da8..2e99f5ff310 100644
--- a/packages/framework/tests/Feature/RobotsTxtPageTest.php
+++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php
@@ -12,7 +12,6 @@
 use Hyde\Foundation\Concerns\HydeExtension;
 use Hyde\Foundation\Kernel\PageCollection;
 use Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator;
-use Hyde\Framework\Features\TextGenerators\RobotsTxtPage;
 use Illuminate\Support\Facades\File;
 
 /**
@@ -22,7 +21,6 @@
  *
  * @see \Hyde\Framework\Testing\Feature\RobotsTxtGeneratorTest
  */
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\TextGenerators\RobotsTxtPage::class)]
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)]
 class RobotsTxtPageTest extends TestCase
 {
@@ -39,7 +37,7 @@ public function testRobotsTxtPageIsRegisteredAsRouteByDefault()
 
         $page = Routes::get('robots.txt')->getPage();
 
-        $this->assertInstanceOf(RobotsTxtPage::class, $page);
+        $this->assertSame(InMemoryPage::class, $page::class);
         $this->assertSame('robots.txt', $page->getOutputPath());
         $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath());
         $this->assertSame('robots.txt', $page->getRouteKey());
@@ -61,7 +59,7 @@ public function testRobotsTxtPageIsNotRegisteredWhenDisabledInConfig()
 
     public function testRobotsTxtPageIsHiddenFromNavigationAndExcludedFromTheSitemap()
     {
-        $page = new RobotsTxtPage();
+        $page = Routes::get('robots.txt')->getPage();
 
         $this->assertFalse($page->showInNavigation());
         $this->assertFalse($page->showInSitemap());
@@ -71,7 +69,7 @@ public function testRobotsTxtPageCompilesUsingTheRobotsTxtGenerator()
     {
         $this->withoutSiteUrl();
 
-        $this->assertSame("User-agent: *\nAllow: /\n", (new RobotsTxtPage())->compile());
+        $this->assertSame("User-agent: *\nAllow: /\n", Routes::get('robots.txt')->getPage()->compile());
     }
 
     public function testRobotsTxtGeneratorCanBeSwappedThroughTheServiceContainer()
@@ -133,7 +131,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedRob
 
         $page = Routes::get('robots.txt')->getPage();
 
-        $this->assertNotInstanceOf(RobotsTxtPage::class, $page);
+        $this->assertSame('user defined robots', $page->compile());
         $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'robots.txt')->count());
 
         $this->artisan('build')->assertExitCode(0);
@@ -147,7 +145,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedRobo
 
         $page = Routes::get('robots.txt')->getPage();
 
-        $this->assertNotInstanceOf(RobotsTxtPage::class, $page);
+        $this->assertSame('extension defined robots', $page->compile());
         $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'robots.txt')->count());
 
         $this->artisan('build')->assertExitCode(0);
diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php
index f72764c2774..9931e624509 100644
--- a/packages/framework/tests/Feature/RssFeedPageTest.php
+++ b/packages/framework/tests/Feature/RssFeedPageTest.php
@@ -13,7 +13,6 @@
 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;
 
 /**
@@ -24,7 +23,6 @@
  * @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
 {
@@ -49,7 +47,7 @@ public function testFeedPageIsRegisteredAsRouteWhenRssFeatureIsEnabled()
 
         $page = Routes::get('feed.xml')->getPage();
 
-        $this->assertInstanceOf(RssFeedPage::class, $page);
+        $this->assertSame(InMemoryPage::class, $page::class);
         $this->assertSame('feed.xml', $page->getOutputPath());
         $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath());
         $this->assertSame('feed.xml', $page->getRouteKey());
@@ -91,12 +89,12 @@ public function testFeedPageUsesConfiguredFilenameVerbatimForAnyExtension()
 
         $this->assertTrue(Routes::exists('feed.rss'));
         $this->assertSame('feed.rss', Routes::get('feed.rss')->getPage()->getOutputPath());
-        $this->assertSame('feed.rss', RssFeedPage::outputPath('feed.rss'));
+        $this->assertSame('feed.rss', InMemoryPage::outputPath('feed.rss'));
     }
 
     public function testFeedPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap()
     {
-        $page = new RssFeedPage();
+        $page = Routes::get('feed.xml')->getPage();
 
         $this->assertFalse($page->showInNavigation());
         $this->assertFalse($page->showInSitemap());
@@ -104,7 +102,7 @@ public function testFeedPageIsHiddenFromNavigationAndExcludesItselfFromTheSitema
 
     public function testFeedPageCompilesUsingTheRssFeedGenerator()
     {
-        $contents = (new RssFeedPage())->compile();
+        $contents = Routes::get('feed.xml')->getPage()->compile();
 
         $this->assertStringStartsWith('<?xml version="1.0" encoding="UTF-8"?>', $contents);
         $this->assertStringContainsString('<rss', $contents);
@@ -170,7 +168,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedFee
 
         $page = Routes::get('feed.xml')->getPage();
 
-        $this->assertNotInstanceOf(RssFeedPage::class, $page);
+        $this->assertSame('user defined feed', $page->compile());
         $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'feed.xml')->count());
 
         $this->artisan('build')->assertExitCode(0);
@@ -184,7 +182,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedFeed
 
         $page = Routes::get('feed.xml')->getPage();
 
-        $this->assertNotInstanceOf(RssFeedPage::class, $page);
+        $this->assertSame('extension defined feed', $page->compile());
         $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'feed.xml')->count());
 
         $this->artisan('build')->assertExitCode(0);
diff --git a/packages/framework/tests/Feature/SitemapFeatureTest.php b/packages/framework/tests/Feature/SitemapFeatureTest.php
index cf7a493bab9..2792a979f58 100644
--- a/packages/framework/tests/Feature/SitemapFeatureTest.php
+++ b/packages/framework/tests/Feature/SitemapFeatureTest.php
@@ -20,7 +20,6 @@
  * @see \Hyde\Framework\Testing\Feature\Commands\BuildSitemapCommandTest
  */
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapGenerator::class)]
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapPage::class)]
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSitemapCommand::class)]
 class SitemapFeatureTest extends TestCase
 {
diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php
index 1e264f9585d..29cfe3ee1ad 100644
--- a/packages/framework/tests/Feature/SitemapPageTest.php
+++ b/packages/framework/tests/Feature/SitemapPageTest.php
@@ -12,7 +12,6 @@
 use Hyde\Foundation\Concerns\HydeExtension;
 use Hyde\Foundation\Kernel\PageCollection;
 use Hyde\Framework\Features\XmlGenerators\SitemapGenerator;
-use Hyde\Framework\Features\XmlGenerators\SitemapPage;
 use Illuminate\Support\Facades\File;
 
 /**
@@ -23,7 +22,6 @@
  * @see \Hyde\Framework\Testing\Feature\SitemapFeatureTest
  * @see \Hyde\Framework\Testing\Feature\Services\SitemapServiceTest
  */
-#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\XmlGenerators\SitemapPage::class)]
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Foundation\HydeCoreExtension::class)]
 class SitemapPageTest extends TestCase
 {
@@ -42,7 +40,7 @@ public function testSitemapPageIsRegisteredAsRouteWhenSitemapFeatureIsEnabled()
 
         $page = Routes::get('sitemap.xml')->getPage();
 
-        $this->assertInstanceOf(SitemapPage::class, $page);
+        $this->assertSame(InMemoryPage::class, $page::class);
         $this->assertSame('sitemap.xml', $page->getOutputPath());
         $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath());
         $this->assertSame('sitemap.xml', $page->getRouteKey());
@@ -65,7 +63,9 @@ public function testSitemapPageIsNotRegisteredWhenSitemapIsDisabledInConfig()
 
     public function testSitemapPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap()
     {
-        $page = new SitemapPage();
+        $this->withSiteUrl();
+
+        $page = Routes::get('sitemap.xml')->getPage();
 
         $this->assertFalse($page->showInNavigation());
         $this->assertFalse($page->showInSitemap());
@@ -75,7 +75,7 @@ public function testSitemapPageCompilesUsingTheSitemapGenerator()
     {
         $this->withSiteUrl();
 
-        $contents = (new SitemapPage())->compile();
+        $contents = Routes::get('sitemap.xml')->getPage()->compile();
 
         $this->assertStringStartsWith('<?xml version="1.0" encoding="UTF-8"?>', $contents);
         $this->assertStringContainsString('<urlset', $contents);
@@ -147,7 +147,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedSit
 
         $page = Routes::get('sitemap.xml')->getPage();
 
-        $this->assertNotInstanceOf(SitemapPage::class, $page);
+        $this->assertSame('user defined sitemap', $page->compile());
         $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'sitemap.xml')->count());
 
         $this->artisan('build')->assertExitCode(0);
@@ -163,7 +163,7 @@ public function testUserPageRegisteredThroughExtensionSuppressesTheGeneratedSite
 
         $page = Routes::get('sitemap.xml')->getPage();
 
-        $this->assertNotInstanceOf(SitemapPage::class, $page);
+        $this->assertSame('extension defined sitemap', $page->compile());
         $this->assertSame(1, Hyde::pages()->filter(fn ($page) => $page->getRouteKey() === 'sitemap.xml')->count());
 
         $this->artisan('build')->assertExitCode(0);

From 9cfd27f90cf339c830dd3943c429cd786789eceb Mon Sep 17 00:00:00 2001
From: Emma De Silva <emma@desilva.se>
Date: Wed, 15 Jul 2026 00:11:19 +0200
Subject: [PATCH 2/5] Remove overzealous config validation

---
 .../TextGenerators/RobotsTxtGenerator.php     | 15 ++-----------
 .../tests/Feature/RobotsTxtGeneratorTest.php  | 21 ++++---------------
 2 files changed, 6 insertions(+), 30 deletions(-)

diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php
index 0cb769462ab..e9fd579182a 100644
--- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php
+++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtGenerator.php
@@ -7,13 +7,9 @@
 use Hyde\Hyde;
 use Hyde\Facades\Config;
 use Hyde\Facades\Features;
-use Hyde\Framework\Exceptions\InvalidConfigurationException;
 
 use function array_merge;
-use function get_debug_type;
 use function implode;
-use function is_string;
-use function sprintf;
 
 /**
  * Generates the contents for the robots.txt file.
@@ -53,15 +49,8 @@ protected function getRuleLines(): array
 
         $lines = [];
 
-        foreach ($rules as $index => $rule) {
-            if (! is_string($rule)) {
-                throw new InvalidConfigurationException(sprintf(
-                    'Invalid `hyde.robots.disallow` entry at index [%s]: each Disallow rule must be a string, %s given.',
-                    $index, get_debug_type($rule)
-                ), 'hyde', 'disallow');
-            }
-
-            $lines[] = "Disallow: $rule";
+        foreach ($rules as $rule) {
+            $lines[] = 'Disallow: '.(string) $rule;
         }
 
         return $lines;
diff --git a/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php b/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php
index 2fb62ab68b9..5f73afb21c1 100644
--- a/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php
+++ b/packages/framework/tests/Feature/RobotsTxtGeneratorTest.php
@@ -5,7 +5,6 @@
 namespace Hyde\Framework\Testing\Feature;
 
 use Hyde\Testing\TestCase;
-use Hyde\Framework\Exceptions\InvalidConfigurationException;
 use Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator;
 
 #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Features\TextGenerators\RobotsTxtGenerator::class)]
@@ -49,24 +48,12 @@ public function testDisallowRulesAreWrittenVerbatim()
         $this->assertSame("User-agent: *\nDisallow: /*.pdf$\nDisallow: \n", $this->generate());
     }
 
-    public function testNonStringDisallowRuleFailsWithConfigurationException()
+    public function testNumericDisallowRulesAreCastToStrings()
     {
-        config(['hyde.robots.disallow' => ['/private', 123]]);
-
-        $this->expectException(InvalidConfigurationException::class);
-        $this->expectExceptionMessage('Invalid `hyde.robots.disallow` entry at index [1]: each Disallow rule must be a string, int given.');
-
-        $this->generate();
-    }
-
-    public function testNonStringDisallowRuleExceptionIdentifiesStringKeys()
-    {
-        config(['hyde.robots.disallow' => ['foo' => null]]);
-
-        $this->expectException(InvalidConfigurationException::class);
-        $this->expectExceptionMessage('Invalid `hyde.robots.disallow` entry at index [foo]: each Disallow rule must be a string, null given.');
+        $this->withoutSiteUrl();
+        config(['hyde.robots.disallow' => [123, 3.14]]);
 
-        $this->generate();
+        $this->assertSame("User-agent: *\nDisallow: 123\nDisallow: 3.14\n", $this->generate());
     }
 
     public function testGeneratesDisallowRulesAndSitemapLineTogether()

From 840324cfae3befeeafe523ebcdfe2dc588f5b80d Mon Sep 17 00:00:00 2001
From: Emma De Silva <emma@desilva.se>
Date: Wed, 15 Jul 2026 00:13:50 +0200
Subject: [PATCH 3/5] Remove repeated runtime validation on static properties

---
 .../hyde-pages-api/hyde-page-methods.md         |  4 +---
 .../framework/src/Pages/Concerns/HydePage.php   | 17 +----------------
 .../framework/tests/Feature/HydePageTest.php    | 14 ++++----------
 3 files changed, 6 insertions(+), 29 deletions(-)

diff --git a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md
index 2b1b770e02a..4307951e7c1 100644
--- a/docs/_data/partials/hyde-pages-api/hyde-page-methods.md
+++ b/docs/_data/partials/hyde-pages-api/hyde-page-methods.md
@@ -1,7 +1,7 @@
 <section id="hyde-page-methods">
 
 <!-- Start generated docs for Hyde\Pages\Concerns\HydePage -->
-<!-- Generated by HydePHP DocGen script at 2026-07-14 01:56:49 in 3.43ms -->
+<!-- Generated by HydePHP DocGen script at 2026-07-14 22:18:28 in 3.45ms -->
 
 #### `make()`
 
@@ -92,8 +92,6 @@ The value includes the leading dot, so it can be used directly as a file name su
 HydePage::outputExtension(): string
 ```
 
-- **Throws:** \InvalidArgumentException If the output extension does not start with a dot or contains a path separator.
-
 #### `setSourceDirectory()`
 
 Set the source directory for the page type.
diff --git a/packages/framework/src/Pages/Concerns/HydePage.php b/packages/framework/src/Pages/Concerns/HydePage.php
index dbf08226941..f78ea430194 100644
--- a/packages/framework/src/Pages/Concerns/HydePage.php
+++ b/packages/framework/src/Pages/Concerns/HydePage.php
@@ -24,16 +24,12 @@
 use Hyde\Support\Models\Route;
 use Hyde\Support\Models\RouteKey;
 use Illuminate\Support\Str;
-use InvalidArgumentException;
 
 use function Hyde\unslash;
 use function filled;
 use function ltrim;
 use function rtrim;
-use function sprintf;
-use function str_contains;
 use function str_ends_with;
-use function str_starts_with;
 
 /**
  * The base class for all Hyde pages.
@@ -183,21 +179,10 @@ public static function sourceExtension(): string
      * Get the output file extension for the page type, such as `.html` or `.txt`.
      *
      * The value includes the leading dot, so it can be used directly as a file name suffix.
-     *
-     * @throws \InvalidArgumentException If the output extension does not start with a dot or contains a path separator.
      */
     public static function outputExtension(): string
     {
-        $extension = static::$outputExtension;
-
-        if (! str_starts_with($extension, '.') || str_contains($extension, '/') || str_contains($extension, '\\')) {
-            throw new InvalidArgumentException(sprintf(
-                "Invalid output extension '%s' declared by %s: extensions must start with a dot and cannot contain path separators.",
-                $extension, static::class
-            ));
-        }
-
-        return $extension;
+        return static::$outputExtension;
     }
 
     /**
diff --git a/packages/framework/tests/Feature/HydePageTest.php b/packages/framework/tests/Feature/HydePageTest.php
index a752e0e7417..36fb949272e 100644
--- a/packages/framework/tests/Feature/HydePageTest.php
+++ b/packages/framework/tests/Feature/HydePageTest.php
@@ -20,7 +20,6 @@
 use Hyde\Pages\MarkdownPost;
 use Hyde\Support\Models\Route;
 use Hyde\Testing\TestCase;
-use InvalidArgumentException;
 
 /**
  * Test the base HydePage class.
@@ -129,19 +128,14 @@ public function testOutputPathUsesTheOutputExtensionOfThePageClass()
         $this->assertSame('output/hello-world.txt', NonHtmlOutputTestPage::outputPath('hello-world'));
     }
 
-    public function testOutputExtensionWithoutLeadingDotThrows()
+    public function testOutputExtensionWithoutLeadingDotIsReturnedUnchanged()
     {
-        $this->expectException(InvalidArgumentException::class);
-        $this->expectExceptionMessage("Invalid output extension 'txt' declared by");
-
-        MissingDotOutputExtensionTestPage::outputExtension();
+        $this->assertSame('txt', MissingDotOutputExtensionTestPage::outputExtension());
     }
 
-    public function testOutputExtensionWithPathSeparatorThrows()
+    public function testOutputExtensionWithPathSeparatorIsReturnedUnchanged()
     {
-        $this->expectException(InvalidArgumentException::class);
-
-        PathSeparatorOutputExtensionTestPage::outputExtension();
+        $this->assertSame('.txt/../evil', PathSeparatorOutputExtensionTestPage::outputExtension());
     }
 
     public function testGetRouteKeyForPageWithNonHtmlOutputExtensionIncludesExtension()

From d09cf4f72286903a32553dc13f22b133b7546deb Mon Sep 17 00:00:00 2001
From: Emma De Silva <emma@desilva.se>
Date: Wed, 15 Jul 2026 00:21:49 +0200
Subject: [PATCH 4/5] Remove the boot-time legacy API guard

---
 HYDEPHP_V3_PLANNING.md                        |  2 +-
 UPGRADE.md                                    |  3 --
 .../src/Foundation/Kernel/FileCollection.php  | 20 -----------
 .../Feature/HydeExtensionFeatureTest.php      | 33 -------------------
 4 files changed, 1 insertion(+), 57 deletions(-)

diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md
index 80df700d147..c5c88f2a34f 100644
--- a/HYDEPHP_V3_PLANNING.md
+++ b/HYDEPHP_V3_PLANNING.md
@@ -48,7 +48,7 @@ Having this document in code lets us know the devlopment state at any given poin
 
 ### Breaking Changes
 
-- Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`, making it explicit that these APIs describe source files. Custom page classes and code calling these APIs need the mechanical rename, which the planned automated upgrade script will handle (see the upgrade script rules section at the end of this document). Page discovery fails fast with an actionable exception for registered page classes that still use the old API, instead of silently skipping them during builds.
+- Renamed the static page class property `$fileExtension` to `$sourceExtension`, and the `fileExtension()` and `setFileExtension()` methods to `sourceExtension()` and `setSourceExtension()`, making it explicit that these APIs describe source files. Custom page classes and code calling these APIs need the mechanical rename, which the planned automated upgrade script will handle (see the upgrade script rules section at the end of this document).
 - 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 fails with an error (exit code 1 instead of 3) when the sitemap cannot be generated — because no base URL is configured or it is disabled in the configuration — instead of generating it anyway in the latter case.
 - 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 fails with an error when the feed cannot be generated (no base URL, disabled in the configuration, or no Markdown posts), instead of silently generating an empty feed. A user-defined page registered under the feed route key is still built even when the feature conditions are not met.
 - 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.
diff --git a/UPGRADE.md b/UPGRADE.md
index 1576c678d44..9924ec2521f 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -285,9 +285,6 @@ The automated upgrade script will handle this rename for ordinary property decla
 method calls, and overridden method declarations. Dynamic references — variable method or property names,
 reflection, and string-based access — must be updated manually.
 
-You do not need to hunt for affected classes: page discovery fails fast with an exception naming any
-registered page class that still uses the old API, instead of silently skipping the class during builds.
-
 ## Migration Checklist
 
 Use this checklist to track your upgrade progress:
diff --git a/packages/framework/src/Foundation/Kernel/FileCollection.php b/packages/framework/src/Foundation/Kernel/FileCollection.php
index 75a4cd4fab3..7659cbbd9df 100644
--- a/packages/framework/src/Foundation/Kernel/FileCollection.php
+++ b/packages/framework/src/Foundation/Kernel/FileCollection.php
@@ -9,11 +9,8 @@
 use Hyde\Framework\Exceptions\FileNotFoundException;
 use Hyde\Pages\Concerns\HydePage;
 use Hyde\Support\Filesystem\SourceFile;
-use RuntimeException;
 
 use function basename;
-use function method_exists;
-use function property_exists;
 use function str_starts_with;
 
 /**
@@ -44,29 +41,12 @@ protected function runDiscovery(): void
     {
         /** @var class-string<\Hyde\Pages\Concerns\HydePage> $pageClass */
         foreach ($this->kernel->getRegisteredPageClasses() as $pageClass) {
-            self::guardAgainstLegacyFileExtensionApi($pageClass);
-
             if ($pageClass::isDiscoverable()) {
                 $this->discoverFilesFor($pageClass);
             }
         }
     }
 
-    /**
-     * Fail fast for page classes still using the file extension API renamed in HydePHP v3.
-     * Without this guard such classes are simply not discoverable, so a build would
-     * succeed while silently omitting the entire page type. Temporary upgrade
-     * aid that can be removed in a future release.
-     *
-     * @param  class-string<HydePage>  $pageClass
-     */
-    protected static function guardAgainstLegacyFileExtensionApi(string $pageClass): void
-    {
-        if (property_exists($pageClass, 'fileExtension') || method_exists($pageClass, 'fileExtension') || method_exists($pageClass, 'setFileExtension')) {
-            throw new RuntimeException("The page class [$pageClass] uses the \$fileExtension API which was renamed in HydePHP v3. Rename \$fileExtension, fileExtension(), and setFileExtension() to \$sourceExtension, sourceExtension(), and setSourceExtension().");
-        }
-    }
-
     protected function runExtensionHandlers(): void
     {
         /** @var class-string<\Hyde\Foundation\Concerns\HydeExtension> $extension */
diff --git a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php
index f074acc165e..4fe121218cf 100644
--- a/packages/framework/tests/Feature/HydeExtensionFeatureTest.php
+++ b/packages/framework/tests/Feature/HydeExtensionFeatureTest.php
@@ -18,7 +18,6 @@
 use Hyde\Support\Models\Route;
 use Hyde\Testing\TestCase;
 use InvalidArgumentException;
-use RuntimeException;
 use stdClass;
 
 /**
@@ -44,16 +43,6 @@ protected function setUp(): void
         $this->kernel = HydeKernel::getInstance();
     }
 
-    public function testDiscoveryFailsFastForPageClassUsingLegacyFileExtensionApi()
-    {
-        $this->kernel->registerExtension(LegacyPageExtension::class);
-
-        $this->expectException(RuntimeException::class);
-        $this->expectExceptionMessage('uses the $fileExtension API which was renamed in HydePHP v3');
-
-        $this->kernel->boot();
-    }
-
     public function testHandlerMethodsAreCalledByDiscovery()
     {
         $this->kernel->registerExtension(HydeTestExtension::class);
@@ -230,28 +219,6 @@ public function compile(): string
     }
 }
 
-class LegacyFileExtensionPageClass extends HydePage
-{
-    public static string $sourceDirectory = 'foo';
-    public static string $outputDirectory = 'foo';
-    public static string $fileExtension = '.txt';
-
-    public function compile(): string
-    {
-        return '';
-    }
-}
-
-class LegacyPageExtension extends HydeExtension
-{
-    public static function getPageClasses(): array
-    {
-        return [
-            LegacyFileExtensionPageClass::class,
-        ];
-    }
-}
-
 class TestPageExtension extends HydeExtension
 {
     public static function getPageClasses(): array

From e409731cd0df2b274a864816aa0b3821624f29e0 Mon Sep 17 00:00:00 2001
From: Emma De Silva <emma@desilva.se>
Date: Wed, 15 Jul 2026 00:34:41 +0200
Subject: [PATCH 5/5] Ensure spec is up to date

---
 EPIC_NON_HTML_PAGES.md | 16 ++++------------
 1 file changed, 4 insertions(+), 12 deletions(-)

diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md
index c4aa9afd80f..4ab5bf22a65 100644
--- a/EPIC_NON_HTML_PAGES.md
+++ b/EPIC_NON_HTML_PAGES.md
@@ -493,18 +493,10 @@ Implementation notes (branch `v3/non-html-pages-robots`):
   existing tests asserting exact collections gained a `hyde.robots.enabled => false`
   in their setup, alongside their existing sitemap/RSS switches.
 - Generator output: `User-agent: *`, then verbatim `Disallow:` lines from the
-  `hyde.robots.disallow` config array, or `Allow: /` when there are none (a group
-  needs at least one rule; an unconditional `Allow: /` next to disallow rules would
-  be noise). The config entries are *rule values*, not filesystem paths — named and
-  documented as such in the config stubs and generator — and are deliberately not
-  normalized (no leading-slash fixup, trimming, or empty-string removal):
-  normalization would guess intent and break valid values like wildcard patterns or
-  the empty string (a valid "allow everything" rule). The verbatim contract is
-  string-only, validated per entry: a non-string value throws an
-  `InvalidConfigurationException` naming `hyde.robots.disallow` and the offending
-  index, instead of surfacing as a PHP-level type error at build time. Later
-  generated text pages copying this pattern (llms.txt) should keep both halves —
-  verbatim strings, explicit validation.
+  `hyde.robots.disallow` config array, or `Allow: /` when there are none. The config entries are
+  *rule values*, not filesystem paths, and are deliberately not normalized
+  (no leading-slash fixup or empty-string removal) so valid values like wildcard patterns are supported.
+  Non-string values (like integers or floats) are safely cast to strings during generation.
 - Its `InMemoryPage` identifier includes the `.txt` output extension per D2.
 - No `build:robots` command: the sitemap/RSS commands exist only as carry-overs of
   the removed post-build tasks; robots.txt never had one, and the standard build
