From 5ba895cb8f6c56e6147e0273df05aa1a1d9a14d1 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 20:35:31 +0200 Subject: [PATCH 1/7] Make in-memory output semantics class-level --- .../DocumentationSearchIndex.php | 4 +- .../Features/TextGenerators/LlmsTxtPage.php | 4 +- .../Features/TextGenerators/RobotsTxtPage.php | 4 +- .../Features/XmlGenerators/RssFeedPage.php | 11 ++- .../Features/XmlGenerators/SitemapPage.php | 4 +- packages/framework/src/Pages/InMemoryPage.php | 66 +--------------- .../Commands/BuildRssFeedCommandTest.php | 5 +- .../Commands/BuildSitemapCommandTest.php | 10 ++- .../tests/Feature/LlmsTxtPageTest.php | 10 ++- .../tests/Feature/NonHtmlPageOutputTest.php | 74 +++++------------- .../tests/Feature/RobotsTxtPageTest.php | 10 ++- .../tests/Feature/RssFeedPageTest.php | 10 ++- .../Feature/Services/SitemapServiceTest.php | 10 ++- .../tests/Feature/SitemapPageTest.php | 10 ++- .../tests/Unit/Pages/InMemoryPageTest.php | 77 ++----------------- .../tests/Unit/Pages/InMemoryPageUnitTest.php | 17 ---- .../framework/tests/Unit/RouteKeyTest.php | 2 +- 17 files changed, 102 insertions(+), 226 deletions(-) diff --git a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php index fc595b61fc9..f55bbd6c5b2 100644 --- a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php +++ b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php @@ -18,6 +18,8 @@ */ class DocumentationSearchIndex extends InMemoryPage { + public static string $outputExtension = '.json'; + protected readonly ?DocumentationVersion $version; public function __construct(?DocumentationVersion $version = null) @@ -26,7 +28,7 @@ public function __construct(?DocumentationVersion $version = null) parent::__construct(static::routeKey($version), [ 'navigation' => ['hidden' => true], - ], exactOutputPath: true); + ]); } public function compile(): string diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php index 782d0946bb9..77949003d61 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php +++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php @@ -17,11 +17,13 @@ */ class LlmsTxtPage extends InMemoryPage { + public static string $outputExtension = '.txt'; + public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ], exactOutputPath: true); + ]); } public function compile(): string diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php index 0e68f158378..5dbe8040b70 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php +++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php @@ -17,11 +17,13 @@ */ class RobotsTxtPage extends InMemoryPage { + public static string $outputExtension = '.txt'; + public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ], exactOutputPath: true); + ]); } public function compile(): string diff --git a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php index 658565a73a4..a1488ad0f6d 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php @@ -7,6 +7,7 @@ use Hyde\Pages\InMemoryPage; use function app; +use function Hyde\unslash; /** * @internal This page is used to render the RSS feed for the site. @@ -21,7 +22,7 @@ public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ], exactOutputPath: true); + ]); } public function compile(): string @@ -36,4 +37,12 @@ public static function routeKey(): string { return RssFeedGenerator::getFilename(); } + + /** + * Use the configured RSS filename as the output path. + */ + public static function outputPath(string $identifier): string + { + return unslash($identifier); + } } diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php index 87673073977..ffc4a38ef77 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php @@ -17,11 +17,13 @@ */ class SitemapPage extends InMemoryPage { + public static string $outputExtension = '.xml'; + public function __construct() { parent::__construct(static::routeKey(), [ 'navigation' => ['hidden' => true], - ], exactOutputPath: true); + ]); } public function compile(): string diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index b8ab81def3c..5b050f9d03d 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -10,13 +10,9 @@ use Hyde\Markdown\Models\FrontMatter; use Hyde\Pages\Concerns\HydePage; use Illuminate\Support\Facades\View; -use InvalidArgumentException; -use function Hyde\unslash; use function sprintf; -use function str_contains; use function str_ends_with; -use function str_starts_with; /** * Extendable class for in-memory (or virtual) Hyde pages that are not based on any source files. @@ -36,7 +32,6 @@ class InMemoryPage extends HydePage protected string $contents; protected string $view; - protected readonly bool $exactOutputPath; /** @var array */ protected array $macros = []; @@ -49,16 +44,6 @@ public static function make(string $identifier = '', FrontMatter|array $matter = return new static($identifier, $matter, $contents, $view); } - /** - * Create an in-memory page whose identifier is used as the exact output path. - * - * The output path must be a relative file path contained within the site output directory. - */ - public static function file(string $outputPath, FrontMatter|array $matter = [], string $contents = '', string $view = ''): static - { - return new static($outputPath, $matter, $contents, $view, exactOutputPath: true); - } - /** * Create a new in-memory/virtual page instance. * @@ -68,7 +53,6 @@ public static function file(string $outputPath, FrontMatter|array $matter = [], * * Note that $contents take precedence over $view, so if you pass both, only $contents will be used. * You can also register a macro with the name 'compile' to overload the default compile method. - * Normal construction uses HTML page semantics; use the `file()` constructor to create an exact-path file page. * * @param string $identifier The identifier of the page. This is used to generate the route key which is used to create the output filename. * If the identifier for an in-memory page is "foo/bar" the page will be saved to "_site/foo/bar.html". @@ -78,61 +62,15 @@ public static function file(string $outputPath, FrontMatter|array $matter = [], * all this data will be passed to the view rendering engine. * @param string $contents The contents of the page. This will be saved as-is to the output file. * @param string $view The view key or Blade file for the view to use to render the page contents. - * @param bool $exactOutputPath Whether to validate and use the identifier as an exact output path. Prefer the `file()` constructor for this mode. */ - public function __construct( - string $identifier = '', - FrontMatter|array $matter = [], - string $contents = '', - string $view = '', - bool $exactOutputPath = false - ) { - if ($exactOutputPath) { - $identifier = static::normalizeExactOutputPath($identifier); - } - - $this->exactOutputPath = $exactOutputPath; - + public function __construct(string $identifier = '', FrontMatter|array $matter = [], string $contents = '', string $view = '') + { parent::__construct($identifier, $matter); $this->contents = $contents; $this->view = $view; } - protected static function normalizeExactOutputPath(string $path): string - { - $segments = explode('/', $path); - - if ( - $path === '' - || str_starts_with($path, '/') - || str_ends_with($path, '/') - || str_contains($path, '\\') - || preg_match('/^[A-Za-z]:/', $path) - || in_array('', $segments, true) - || in_array('.', $segments, true) - || in_array('..', $segments, true) - ) { - throw new InvalidArgumentException( - "Invalid exact output path [$path]. The path must be a relative file path inside the site output directory." - ); - } - - return unslash($path); - } - - /** - * Get the path where the compiled page will be saved. - */ - public function getOutputPath(): string - { - if ($this->exactOutputPath) { - return unslash($this->identifier); - } - - return parent::getOutputPath(); - } - /** Get the contents of the page. This will be saved as-is to the output file when this strategy is used. */ public function getContents(): string { diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index 5d44aa1884e..0321cca5541 100644 --- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php @@ -88,7 +88,10 @@ public function testCommandBuildsUserDefinedFeedPageEvenWhenRssFeatureConditions $this->cleanUpWhenDone('_site/feed.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('feed.xml', contents: '')); + $kernel->pages()->addPage(new class('feed.xml', contents: '') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); }); $this->artisan('build:rss')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php index 3822aa4e38e..6c10895029d 100644 --- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php @@ -60,7 +60,10 @@ public function testCommandBuildsUserDefinedSitemapPageWhenOneIsRegistered() $this->cleanUpWhenDone('_site/sitemap.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: '')); + $kernel->pages()->addPage(new class('sitemap.xml', contents: '') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); }); $this->artisan('build:sitemap')->assertExitCode(0); @@ -76,7 +79,10 @@ public function testCommandBuildsUserDefinedSitemapPageEvenWhenSitemapFeatureIsD $this->cleanUpWhenDone('_site/sitemap.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: '')); + $kernel->pages()->addPage(new class('sitemap.xml', contents: '') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); }); $this->artisan('build:sitemap')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index 1b3b65ebc80..55f722ee813 100644 --- a/packages/framework/tests/Feature/LlmsTxtPageTest.php +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -128,7 +128,10 @@ public function testLlmsTxtRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedLlmsTxtPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('llms.txt', contents: 'user defined llms')); + $kernel->pages()->addPage(new class('llms.txt', contents: 'user defined llms') extends InMemoryPage + { + public static string $outputExtension = '.txt'; + }); }); $page = Routes::get('llms.txt')->getPage(); @@ -160,6 +163,9 @@ class LlmsTxtPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(InMemoryPage::file('llms.txt', contents: 'extension defined llms')); + $collection->addPage(new class('llms.txt', contents: 'extension defined llms') extends InMemoryPage + { + public static string $outputExtension = '.txt'; + }); } } diff --git a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php index 28e4dc372fd..25e25c0b7d8 100644 --- a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php +++ b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php @@ -15,9 +15,7 @@ use Hyde\Framework\Actions\StaticPageBuilder; /** - * Feature test for compiling in-memory pages to exact output paths, - * covering the user path of registering pages like robots.txt in code and having - * them compiled to their specified output paths by the standard build process. + * Feature test for compiling custom page classes with non-HTML output extensions. * * @see \Hyde\Framework\Testing\Unit\Pages\InMemoryPageTest */ @@ -32,10 +30,10 @@ protected function tearDown(): void parent::tearDown(); } - public function testBuildCommandCompilesExactTxtOutputPath() + public function testBuildCommandCompilesInMemoryPageSubclassWithTxtOutputExtension() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: "User-agent: *\nAllow: /")); + $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: "User-agent: *\nAllow: /")); }); $this->artisan('build')->assertExitCode(0); @@ -45,34 +43,10 @@ public function testBuildCommandCompilesExactTxtOutputPath() $this->assertFileDoesNotExist(Hyde::path('_site/robots.txt.html')); } - public function testBuildCommandCompilesExactJsonOutputPath() + public function testBuildCommandCompilesNestedNonHtmlOutputPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('data.json', contents: '{"foo": "bar"}')); - }); - - $this->artisan('build')->assertExitCode(0); - - $this->assertFileExists(Hyde::path('_site/data.json')); - $this->assertSame('{"foo": "bar"}', file_get_contents(Hyde::path('_site/data.json'))); - } - - public function testBuildCommandCompilesExactXmlOutputPath() - { - Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('custom.xml', contents: '')); - }); - - $this->artisan('build')->assertExitCode(0); - - $this->assertFileExists(Hyde::path('_site/custom.xml')); - $this->assertSame('', file_get_contents(Hyde::path('_site/custom.xml'))); - } - - public function testBuildCommandCompilesNestedExactOutputPath() - { - Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('foo/bar.txt', contents: 'baz')); + $kernel->pages()->addPage(new InMemoryTxtTestPage('foo/bar', contents: 'baz')); }); $this->artisan('build')->assertExitCode(0); @@ -81,57 +55,40 @@ public function testBuildCommandCompilesNestedExactOutputPath() $this->assertSame('baz', file_get_contents(Hyde::path('_site/foo/bar.txt'))); } - public function testBuildCommandCompilesArbitraryExactOutputPathsWithoutExtensionInference() - { - Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('site.webmanifest', contents: 'manifest')); - $kernel->pages()->addPage(InMemoryPage::file('sitemap.xsl', contents: 'stylesheet')); - $kernel->pages()->addPage(InMemoryPage::file('downloads/data.csv', contents: 'csv')); - $kernel->pages()->addPage(InMemoryPage::file('feed', contents: 'feed')); - }); - - $this->artisan('build')->assertExitCode(0); - - $this->assertSame('manifest', file_get_contents(Hyde::path('_site/site.webmanifest'))); - $this->assertSame('stylesheet', file_get_contents(Hyde::path('_site/sitemap.xsl'))); - $this->assertSame('csv', file_get_contents(Hyde::path('_site/downloads/data.csv'))); - $this->assertSame('feed', file_get_contents(Hyde::path('_site/feed'))); - } - - public function testExactOutputPageIsRegisteredAsRoute() + public function testNonHtmlInMemoryPageIsRegisteredAsRoute() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: 'User-agent: *')); + $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: 'User-agent: *')); }); $this->assertTrue(Routes::exists('robots.txt')); $this->assertSame('robots.txt', Routes::get('robots.txt')->getOutputPath()); } - public function testStaticPageBuilderCompilesExactOutputPage() + public function testStaticPageBuilderCompilesNonHtmlInMemoryPage() { - StaticPageBuilder::handle(InMemoryPage::file('llms.txt', contents: '# Hello World')); + StaticPageBuilder::handle(new InMemoryTxtTestPage('llms', contents: '# Hello World')); $this->assertFileExists(Hyde::path('_site/llms.txt')); $this->assertSame('# Hello World', file_get_contents(Hyde::path('_site/llms.txt'))); } - public function testExactOutputPageCanCompileUsingView() + public function testNonHtmlInMemoryPageCanCompileUsingView() { $this->file('_pages/robots.blade.php', 'User-agent: {{ $agent }}'); - StaticPageBuilder::handle(InMemoryPage::file('robots.txt', ['agent' => '*'], view: 'robots')); + StaticPageBuilder::handle(new InMemoryTxtTestPage('robots', ['agent' => '*'], view: 'robots')); $this->assertFileExists(Hyde::path('_site/robots.txt')); $this->assertSame('User-agent: *', file_get_contents(Hyde::path('_site/robots.txt'))); } - public function testBuildCommandExcludesExactNonHtmlPageFromSitemap() + public function testBuildCommandExcludesNonHtmlInMemoryPageFromSitemap() { $this->withSiteUrl(); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: 'User-agent: *')); + $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: 'User-agent: *')); }); $this->artisan('build')->assertExitCode(0); @@ -161,6 +118,11 @@ public function testBuildCommandCompilesDiscoverableCustomPageClassWithNonHtmlOu } } +class InMemoryTxtTestPage extends InMemoryPage +{ + public static string $outputExtension = '.txt'; +} + class DiscoverableNonHtmlTestPage extends HydePage { public static string $sourceDirectory = '_leaves'; diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php index 00a66ce3f6b..f7b9435e05c 100644 --- a/packages/framework/tests/Feature/RobotsTxtPageTest.php +++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php @@ -127,7 +127,10 @@ public function testRobotsTxtRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedRobotsTxtPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('robots.txt', contents: 'user defined robots')); + $kernel->pages()->addPage(new class('robots.txt', contents: 'user defined robots') extends InMemoryPage + { + public static string $outputExtension = '.txt'; + }); }); $page = Routes::get('robots.txt')->getPage(); @@ -159,6 +162,9 @@ class RobotsTxtPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(InMemoryPage::file('robots.txt', contents: 'extension defined robots')); + $collection->addPage(new class('robots.txt', contents: 'extension defined robots') extends InMemoryPage + { + public static string $outputExtension = '.txt'; + }); } } diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php index a7d7853896f..a8efed9e178 100644 --- a/packages/framework/tests/Feature/RssFeedPageTest.php +++ b/packages/framework/tests/Feature/RssFeedPageTest.php @@ -163,7 +163,10 @@ public function testFeedRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedFeedPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('feed.xml', contents: 'user defined feed')); + $kernel->pages()->addPage(new class('feed.xml', contents: 'user defined feed') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); }); $page = Routes::get('feed.xml')->getPage(); @@ -195,6 +198,9 @@ class RssFeedPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(InMemoryPage::file('feed.xml', contents: 'extension defined feed')); + $collection->addPage(new class('feed.xml', contents: 'extension defined feed') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); } } diff --git a/packages/framework/tests/Feature/Services/SitemapServiceTest.php b/packages/framework/tests/Feature/Services/SitemapServiceTest.php index 35358e1c73b..b52951d4fa2 100644 --- a/packages/framework/tests/Feature/Services/SitemapServiceTest.php +++ b/packages/framework/tests/Feature/Services/SitemapServiceTest.php @@ -135,7 +135,10 @@ public function testGenerateDoesNotAddPagesWithSitemapFrontMatterSetToQuotedFals public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() { - Routes::addRoute(new Route(InMemoryPage::file('robots.txt'))); + Routes::addRoute(new Route(new class('robots.txt') extends InMemoryPage + { + public static string $outputExtension = '.txt'; + })); $service = new SitemapGenerator(); $service->generate(); @@ -146,7 +149,10 @@ public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() public function testGenerateAddsNonHtmlPagesWithSitemapFrontMatterSetToTrue() { - Routes::addRoute(new Route(InMemoryPage::file('robots.txt', ['sitemap' => true]))); + Routes::addRoute(new Route(new class('robots.txt', ['sitemap' => true]) extends InMemoryPage + { + public static string $outputExtension = '.txt'; + })); $service = new SitemapGenerator(); $service->generate(); diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php index 873146887b1..8180aef9173 100644 --- a/packages/framework/tests/Feature/SitemapPageTest.php +++ b/packages/framework/tests/Feature/SitemapPageTest.php @@ -141,7 +141,10 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedSit $this->withSiteUrl(); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: 'user defined sitemap')); + $kernel->pages()->addPage(new class('sitemap.xml', contents: 'user defined sitemap') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); }); $page = Routes::get('sitemap.xml')->getPage(); @@ -175,6 +178,9 @@ class SitemapPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(InMemoryPage::file('sitemap.xml', contents: 'extension defined sitemap')); + $collection->addPage(new class('sitemap.xml', contents: 'extension defined sitemap') extends InMemoryPage + { + public static string $outputExtension = '.xml'; + }); } } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index f20206ab5b9..c430008c437 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -7,8 +7,6 @@ use BadMethodCallException; use Hyde\Pages\InMemoryPage; use Hyde\Testing\TestCase; -use InvalidArgumentException; -use PHPUnit\Framework\Attributes\DataProvider; /** * @see \Hyde\Framework\Testing\Unit\Pages\InMemoryPageUnitTest @@ -27,12 +25,6 @@ public function testMakeWithContentsString() $this->assertEquals(InMemoryPage::make('foo', contents: 'bar'), new InMemoryPage('foo', contents: 'bar')); } - public function testFileWithContentsString() - { - $this->assertInstanceOf(InMemoryPage::class, InMemoryPage::file('robots.txt', contents: 'bar')); - $this->assertSame('robots.txt', InMemoryPage::file('robots.txt', contents: 'bar')->getOutputPath()); - } - public function testContentsMethod() { $this->assertSame('bar', (new InMemoryPage('foo', contents: 'bar'))->getContents()); @@ -164,68 +156,13 @@ public function testOutputPathUsesNormalHtmlPageSemantics() $this->assertSame('docs/1.x.html', InMemoryPage::outputPath('docs/1.x')); } - public function testFileUsesAnyOutputPathExactly() - { - $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getOutputPath()); - $this->assertSame('site.webmanifest', InMemoryPage::file('site.webmanifest')->getOutputPath()); - $this->assertSame('sitemap.xsl', InMemoryPage::file('sitemap.xsl')->getOutputPath()); - $this->assertSame('downloads/data.csv', InMemoryPage::file('downloads/data.csv')->getOutputPath()); - $this->assertSame('feed', InMemoryPage::file('feed')->getOutputPath()); - $this->assertSame('docs/1.x/search.json', InMemoryPage::file('docs/1.x/search.json')->getOutputPath()); - } - - #[DataProvider('invalidExactOutputPaths')] - public function testFileRejectsInvalidOutputPaths(string $path): void - { - $this->expectException(InvalidArgumentException::class); - - InMemoryPage::file($path); - } - - public static function invalidExactOutputPaths(): array - { - return [ - 'empty' => [''], - 'absolute' => ['/robots.txt'], - 'traversal' => ['../robots.txt'], - 'nested traversal' => ['foo/../../robots.txt'], - 'directory' => ['foo/'], - 'windows separator' => ['foo\\robots.txt'], - 'windows absolute' => ['C:\\robots.txt'], - 'dot' => ['.'], - 'leading dot segment' => ['./robots.txt'], - 'nested dot segment' => ['foo/./robots.txt'], - 'empty segment' => ['foo//robots.txt'], - ]; - } - - public function testGetRouteKeyForFile() - { - $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getRouteKey()); - $this->assertSame('feed', InMemoryPage::file('feed')->getRouteKey()); - } - - public function testGetLinkForFile() - { - $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getLink()); - } - - public function testGetLinkForFileIsNotAffectedByPrettyUrls() - { - config(['hyde.pretty_urls' => true]); - - $this->assertSame('robots.txt', InMemoryPage::file('robots.txt')->getLink()); - } - - public function testGetCanonicalUrlForFile() - { - config(['hyde.url' => 'https://example.com']); - - $this->assertSame('https://example.com/robots.txt', InMemoryPage::file('robots.txt')->getCanonicalUrl()); - } - - public function testCompiledContentsAreNotAffectedByExactOutputPath() + public function testStaticAndInstanceOutputPathsUseTheSameSemantics() { - $this->assertSame('User-agent: *', InMemoryPage::file('robots.txt', contents: 'User-agent: *')->compile()); + foreach (['foo', 'robots.txt', 'docs/search.json'] as $identifier) { + $this->assertSame( + InMemoryPage::outputPath($identifier), + (new InMemoryPage($identifier))->getOutputPath() + ); + } } } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php index 3b770e28c64..505663f37cc 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageUnitTest.php @@ -113,14 +113,6 @@ public function testMake() $this->assertEquals(InMemoryPage::make('foo'), new InMemoryPage('foo')); } - public function testFile() - { - $this->assertEquals( - InMemoryPage::file('robots.txt', contents: 'User-agent: *'), - new InMemoryPage('robots.txt', contents: 'User-agent: *', exactOutputPath: true) - ); - } - public function testMakeWithData() { $this->assertEquals( @@ -140,15 +132,6 @@ public function testShowInSitemap() $this->assertFalse((new InMemoryPage('foo', ['sitemap' => false]))->showInSitemap()); } - public function testShowInSitemapIsFalseForPagesWithNonHtmlOutputPaths() - { - $this->assertFalse(InMemoryPage::file('robots.txt')->showInSitemap()); - $this->assertFalse(InMemoryPage::file('data.json')->showInSitemap()); - $this->assertFalse(InMemoryPage::file('custom.xml')->showInSitemap()); - - $this->assertTrue(InMemoryPage::file('robots.txt', ['sitemap' => true])->showInSitemap()); - } - public function testNavigationMenuPriority() { $this->assertSame(999, (new InMemoryPage('foo'))->navigationMenuPriority()); diff --git a/packages/framework/tests/Unit/RouteKeyTest.php b/packages/framework/tests/Unit/RouteKeyTest.php index d5d7a2252ae..906224ea9c7 100644 --- a/packages/framework/tests/Unit/RouteKeyTest.php +++ b/packages/framework/tests/Unit/RouteKeyTest.php @@ -82,7 +82,7 @@ public function testFromPageWithInMemoryPage() $this->assertEquals(new RouteKey('foo/bar'), RouteKey::fromPage(InMemoryPage::class, 'foo/bar')); } - public function testFromPageWithInMemoryPageIdentifierDeclaringOutputExtension() + public function testFromPageWithDottedInMemoryPageIdentifier() { $this->assertEquals(new RouteKey('robots.txt'), RouteKey::fromPage(InMemoryPage::class, 'robots.txt')); $this->assertEquals(new RouteKey('docs/search.json'), RouteKey::fromPage(InMemoryPage::class, 'docs/search.json')); From 911e350aff411efb509768b54a49f559e3034565 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 20:36:41 +0200 Subject: [PATCH 2/7] Document class-level non-HTML page output --- HYDEPHP_V3_PLANNING.md | 2 +- UPGRADE.md | 25 ++++++++++-------- .../hyde-pages-api/in-memory-page-methods.md | 25 +++--------------- docs/advanced-features/hyde-pages.md | 14 +++++----- docs/advanced-features/in-memory-pages.md | 26 ++++++++++++++----- 5 files changed, 43 insertions(+), 49 deletions(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 651a4bc77c2..52226cce4c3 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -23,7 +23,7 @@ Having this document in code lets us know the devlopment state at any given poin - Added native support for versioned documentation pages. Register versions in the new `docs.versions` configuration option, and store the pages for each version in a matching subdirectory of the documentation source directory (like `_docs/1.x` and `_docs/2.x`). Each version is compiled to a matching subdirectory of the documentation output directory, and gets its own sidebar, search index, and search page. A version switcher dropdown is shown in the documentation sidebar, the main navigation links to the default version's index page, and a redirect page is generated at the documentation root pointing to the default version. Sidebar and search configuration entries (`docs.sidebar.order`, `docs.sidebar.labels`, `docs.sidebar.exclude`, and `docs.exclude_from_search`) match version-agnostic identifiers and route keys, so a single entry applies to the page in every version, while full versioned keys allow version-specific overrides. Enabling the feature is all or nothing: documentation source files stored outside the version directories are ignored, so pages that should live at the documentation root belong in the normal page source directory (like `_pages/docs/index.md`). Versioning is disabled by default, and single-version sites are unaffected. ([#2516](https://github.com/hydephp/develop/pull/2516)) - Redirects can now be declared as source and destination path pairs in the `hyde.redirects` configuration array. Hyde registers them with the kernel, includes them in `route:list`, and generates them through the normal site build. - 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)) -- Added `InMemoryPage::file()` for creating virtual pages whose identifier is used as the exact output path, allowing files such as `robots.txt`, `site.webmanifest`, nested JSON files, and extensionless outputs without extension inference. `InMemoryPage::make()` retains normal HTML page semantics. Custom page classes can compile to non-HTML files by setting the new static `$outputExtension` property (defaulting to `.html`). 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. +- Custom page classes can compile to non-HTML files by setting the new static `$outputExtension` property (defaulting to `.html`). 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 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. - Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. diff --git a/UPGRADE.md b/UPGRADE.md index 011a83e1c51..3b289bdffcd 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -2,18 +2,16 @@ ## Overview -HydePHP v3 adds `InMemoryPage::file()` for creating virtual pages whose identifier is used as the exact output path, -allowing files such as `robots.txt`, `site.webmanifest`, nested JSON files, and extensionless outputs without extension -inference. Normal `InMemoryPage::make()` construction retains its historical HTML behavior: +HydePHP v3 lets custom page classes declare their output extension. `InMemoryPage` generates HTML by default; extend it +and set the static `$outputExtension` property when creating an in-memory page for another format: ```php -InMemoryPage::make('about', contents: $html); -// _site/about.html - -InMemoryPage::make('robots.txt', contents: $text); -// _site/robots.txt.html +class TextFilePage extends InMemoryPage +{ + public static string $outputExtension = '.txt'; +} -InMemoryPage::file('robots.txt', contents: $text); +TextFilePage::make('robots', contents: $text); // _site/robots.txt ``` @@ -207,8 +205,13 @@ The same works for `RssFeedGenerator`. use Hyde\Hyde; use Hyde\Pages\InMemoryPage; -Hyde::kernel()->booting(function ($kernel): void { - $kernel->pages()->addPage(InMemoryPage::file('sitemap.xml', contents: $myXml)); +class CustomSitemapPage extends InMemoryPage +{ + public static string $outputExtension = '.xml'; +} + +Hyde::kernel()->booting(function ($kernel) use ($myXml): void { + $kernel->pages()->addPage(new CustomSitemapPage('sitemap', contents: $myXml)); }); ``` diff --git a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md index 5f94bbf096d..fb5ea70654c 100644 --- a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md @@ -1,7 +1,7 @@
- + #### `make()` @@ -11,40 +11,21 @@ Static alias for the constructor. InMemoryPage::make(string $identifier, Hyde\Markdown\Models\FrontMatter|array $matter, string $contents, string $view): static ``` -#### `file()` - -Create an in-memory page whose identifier is used as the exact output path. - -The output path must be a relative file path contained within the site output directory. - -```php -InMemoryPage::file(string $outputPath, Hyde\Markdown\Models\FrontMatter|array $matter, string $contents, string $view): static -``` - #### `__construct()` Create a new in-memory/virtual page instance. The in-memory page class offers two content options. You can either pass a string to the $contents parameter, Hyde will then save that literally as the page's contents. Alternatively, you can pass a view name to the $view parameter, and Hyde will use that view to render the page contents with the supplied front matter during the static site build process. -Note that $contents take precedence over $view, so if you pass both, only $contents will be used. You can also register a macro with the name 'compile' to overload the default compile method. Normal construction uses HTML page semantics; use the `file()` constructor to create an exact-path file page. +Note that $contents take precedence over $view, so if you pass both, only $contents will be used. You can also register a macro with the name 'compile' to overload the default compile method. If the identifier for an in-memory page is "foo/bar" the page will be saved to "_site/foo/bar.html". You can then also use the route helper to get a link to it by using the route key "foo/bar". Take note that the identifier must be unique to prevent overwriting other pages. all this data will be passed to the view rendering engine. - **Parameter $view:** The view key or Blade file for the view to use to render the page contents. - **Parameter $matter:** The front matter of the page. When using the Blade view rendering option, -- **Parameter $exactOutputPath:** Whether to validate and use the identifier as an exact output path. Prefer the `file()` constructor for this mode. - - -```php -$page = new InMemoryPage(string $identifier, \Hyde\Markdown\Models\FrontMatter|array $matter, string $contents, string $view, bool $exactOutputPath): void -``` - -#### `getOutputPath()` -Get the path where the compiled page will be saved. ```php -$page->getOutputPath(): string +$page = new InMemoryPage(string $identifier, \Hyde\Markdown\Models\FrontMatter|array $matter, string $contents, string $view): void ``` #### `getContents()` diff --git a/docs/advanced-features/hyde-pages.md b/docs/advanced-features/hyde-pages.md index fa5f9f5f6a7..a49602deecc 100644 --- a/docs/advanced-features/hyde-pages.md +++ b/docs/advanced-features/hyde-pages.md @@ -161,16 +161,15 @@ autodiscovery, you may benefit from creating a custom page class instead, as tha You can learn more about the InMemoryPage class in the [InMemoryPage documentation](in-memory-pages). -Normal in-memory pages compile as HTML, regardless of dots in their identifiers. Use `file()` to opt into an exact output path: +In-memory pages generate HTML by default. Custom subclasses can generate another format by declaring their output extension: ```php -InMemoryPage::make('about', contents: $html); -// _site/about.html - -InMemoryPage::make('robots.txt', contents: $text); -// _site/robots.txt.html +class TextFilePage extends InMemoryPage +{ + public static string $outputExtension = '.txt'; +} -InMemoryPage::file('robots.txt', contents: $text); +TextFilePage::make('robots', contents: $text); // _site/robots.txt ``` @@ -198,7 +197,6 @@ class InMemoryPage extends HydePage protected string $contents; protected string $view; - protected readonly bool $exactOutputPath; /** @var array */ protected array $macros = []; diff --git a/docs/advanced-features/in-memory-pages.md b/docs/advanced-features/in-memory-pages.md index e6bd25e0de9..c288f91a59b 100644 --- a/docs/advanced-features/in-memory-pages.md +++ b/docs/advanced-features/in-memory-pages.md @@ -34,22 +34,34 @@ To create an InMemoryPage, you need to instantiate it with the required paramete Since a page would not be useful without any content to render, the class offers two content options through the constructor. You can either pass a string to the `$contents` parameter, Hyde will then save that literally as the page's contents. -Normal construction always uses HTML page semantics, even when the identifier contains a dot. Use `file()` when the -identifier should instead be used as the exact output path: ```php InMemoryPage::make('about', contents: $html); // _site/about.html +``` + +In-memory pages generate HTML by default. To generate another format, extend `InMemoryPage` and declare the output +extension on the page class: -InMemoryPage::make('robots.txt', contents: $text); -// _site/robots.txt.html +```php +class TextFilePage extends InMemoryPage +{ + public static string $outputExtension = '.txt'; +} -InMemoryPage::file('robots.txt', contents: $text); +TextFilePage::make('robots', contents: $text); // _site/robots.txt ``` -The route key and output path are exactly the relative file path passed to `file()`. This supports files such as -`site.webmanifest`, nested JSON files, and extensionless outputs without inferring behavior from the filename. +The output extension belongs to the page type, ensuring static and instance path resolution use the same rules: + +```php +TextFilePage::outputPath('robots'); +// robots.txt + +TextFilePage::make('robots')->getOutputPath(); +// robots.txt +``` Alternatively, you can pass a Blade view name to the `$view` parameter, and Hyde will use that view to render the page contents with the supplied front matter during the static site build process. From 9aeb35d478a430ebd9763ee1d292bf799a2d506e Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 20:37:57 +0200 Subject: [PATCH 3/7] Record class-level output path design decision --- EPIC_NON_HTML_PAGES.md | 116 ++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 64 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index a2459a54d7e..e5878be2c1d 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -38,8 +38,8 @@ require every output format to have a matching filesystem-discovered page class. `docs/2.x/search.json`. There is no per-page sitemap exclusion mechanism at all. - **`hyde serve` does not serve `sitemap.xml` or the RSS feed**, because they only exist as post-build artifacts. -- **`InMemoryPage` has no unambiguous exact-file construction mode**, making the - natural "assemble a non-HTML file in code" escape hatch depend on inference. +- **Custom page classes cannot declare a non-HTML output format**, forcing them to + override path resolution and making route-key and output-path drift possible. - **The realtime compiler special-cases `search.json` by string suffix** instead of asking the route system. @@ -76,35 +76,36 @@ and `docs/search.json` (index) coexist as distinct routes, which they already do > D1-compliant out of the box and PR 2 can rely on "route key == request path with > only `.html` stripped" universally. -### D2: Exact output paths are explicit, not inferred +### D2: Output extensions are declared by the page class, not inferred Do **not** infer "this identifier has an extension" from a dot in the identifier — versioned docs route keys like `docs/1.x/index` would false-positive (`pathinfo('docs/1.x')['extension'] === 'x'`). Instead, output intent is explicit: -- File-discovered custom classes configure their output extension statically, - mirroring the existing `HtmlPage::$sourceExtension` pattern. -- `HydePage` gets `public static string $outputExtension = '.html'` (or an - instance-level hook), and `outputPath()` uses it instead of the hardcoded `'.html'`. -- `InMemoryPage` has two unambiguous construction modes: `make()` uses normal HTML - page semantics, while `file()` validates and uses its identifier as the exact - relative output path. - -> **Final decision (before PR 8): explicit page versus file construction.** The -> allowlist introduced in PR 1 was removed after review because it still inferred -> output behavior and merely relocated the original trap: `.txt` was treated as a -> file while `.webmanifest` was not. `InMemoryPage::make('docs/1.x')` now always -> produces `docs/1.x.html`, and `InMemoryPage::file('robots.txt')` produces exactly -> `robots.txt`. The file mode also handles `site.webmanifest`, `sitemap.xsl`, nested -> `downloads/data.csv`, and extensionless `feed` paths without special cases. +- `HydePage` gets `public static string $outputExtension = '.html'`, and both route-key + and output-path resolution use it. +- File-discovered and in-memory custom page classes declare another extension by + redeclaring that static property, mirroring the existing source path properties. +- A plain `InMemoryPage` always uses its class's HTML semantics. Dots in an identifier + carry no output-format meaning. + +> **Final design decision (before PR 8): output behavior remains class-level.** Two +> instance-level designs were implemented during development and rejected before +> release. The first inferred non-HTML behavior from an allowlist of identifier +> suffixes; it merely moved the trap because `.txt` worked while `.webmanifest` did +> not. The second added `InMemoryPage::file()` and an `$exactOutputPath` instance flag. +> It supported arbitrary paths, but broke the `HydePage` static/instance contract: +> `InMemoryPage::outputPath('robots.txt')` returned `robots.txt.html` while the file +> instance's `getOutputPath()` returned `robots.txt`. Compiler integrations are +> allowed to resolve paths statically, so the disagreement was unsafe. > -> The two output mechanisms are now both declarative: file-discovered custom page -> classes use the per-class static `$outputExtension`, while individual virtual -> files use `InMemoryPage::file()`. First-party generated files opt into exact-path -> mode directly. In particular, `RssFeedPage` no longer overrides an inference hook; -> its configurable filename is inherently an exact output path. Redirects retain -> normal HTML semantics even when their route identifiers contain dots, so a redirect -> identifier of `legacy.json` compiles to `legacy.json.html`. +> Both designs were removed. Non-HTML behavior is represented by a page subclass with +> a static `$outputExtension`, keeping `Page::outputPath($identifier)` equal to the +> corresponding instance's `getOutputPath()`. First-party generated pages follow the +> same rule. `RssFeedPage` is the narrow exception because its configured filename may +> use any extension or none; it overrides the static `outputPath()` method, so its +> static and instance resolution still agree. Redirects and plain `InMemoryPage` +> instances retain HTML semantics even when their identifiers contain dots. ### D3: Sitemap inclusion becomes a page-level concern @@ -117,17 +118,11 @@ 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* (e.g. the extension of `getOutputPath()`), not from the static -> `outputExtension()` accessor. For file-discovered custom pages the two agree, but -> per D2 an `InMemoryPage::file()` instance uses an exact output path while its static -> `outputExtension()` stays `.html`. So a `robots.txt` / `sitemap.xml` / `llms.txt` -> file page reports `.html` statically despite compiling to a non-HTML file. -> Keying the non-HTML default off `getOutputPath()` makes all four generated pages -> self-exclude correctly; keying it off `outputExtension()` would silently -> re-introduce the exact `search.json` leak this epic exists to fix. This is the -> kind of "rule implemented only for the cases the current PR exercised" trap the -> agent workflow warns about — the discovered-page tests would pass while the -> InMemoryPage-backed generated pages regress. +> 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 +> extension. Keying the default off `getOutputPath()` makes all generated pages +> self-exclude correctly and prevents the `search.json` leak from returning. > **Implemented (PR 4):** `HydePage::showInSitemap()` reads the `sitemap` front > matter key, defaulting to whether the resolved output path (`getOutputPath()`) @@ -254,10 +249,9 @@ container → fully custom page in code. First-class non-HTML support is about a page's output path and participation in the route/build/serve lifecycle; it does not require a dedicated source-backed page class -for each file extension. `InMemoryPage::file('robots.txt', contents: ...)` already -provides the full lifecycle integration and is a better fit for the dynamic content -advanced users commonly need, while the planned generated robots and llms pages cover -the common cases without any source file at all. +for each file extension. A small `InMemoryPage` subclass declaring `$outputExtension` +provides the full lifecycle integration and is a better fit for dynamic content, while +the generated robots and llms pages cover the common cases without source files. A core `TextPage` would add only the convenience of autodiscovering `_pages/*.txt`, while creating pressure for parallel `XmlPage`, `JsonPage`, and similar classes. @@ -270,16 +264,15 @@ an extension point. ## Work breakdown (planned PR sequence, in dependency order) -### PR 1 — Foundation: explicit output paths and page-class extensions ✅ Implemented +### PR 1 — Foundation: page-class output extensions ✅ Implemented Goal: any page class can emit a non-`.html` file without overriding `getOutputPath()`. - Add `$outputExtension` (default `'.html'`) to `HydePage`; use it in `outputPath()` (`HydePage.php:211-214`). - Route keys follow D1; audit `RouteKey` and `Route` for assumptions. -- Add explicit exact-path construction for `InMemoryPage` per D2, so - `InMemoryPage::file('robots.txt', contents: ...)` outputs `robots.txt`. -- Refactor `DocumentationSearchIndex` to use the exact-path file-page mode. +- Keep `InMemoryPage` on the same class-level output path contract as other pages. +- Refactor `DocumentationSearchIndex` to declare its `.json` output extension. - Pure refactor for existing sites: no compiled-output changes. Implementation notes (branch `v3/non-html-pages-foundation`): @@ -299,15 +292,11 @@ Implementation notes (branch `v3/non-html-pages-foundation`): "Upgrade script rules" for the release-time Rector script. - Page-class output extension handling was placed in `RouteKey::fromPage()` (see D1 note) rather than only in `outputPath()`, so route keys and output paths cannot drift. -- **Revised before PR 8:** the original allowlist-based implementation was replaced - by `InMemoryPage::file()` (see the final D2 decision). `make()` and direct - construction consistently retain HTML output semantics, while exact-path file - instances work without an extension-specific subclass or inference. -- The two output-extension mechanisms coexist intentionally — per-class static for - discovered classes, per-instance exact-path construction for `InMemoryPage`. - The one constraint this places downstream is recorded in the D3 implementation - note: sitemap / non-HTML detection must read the resolved output path, not the - static `outputExtension()` accessor. +- **Revised before PR 8:** both identifier-suffix inference and the later + instance-level exact-path factory were removed (see D2). Output behavior is + class-level for discovered and in-memory pages alike. +- Sitemap / non-HTML detection reads the resolved output path, not just the static + extension declaration, so specialized static path overrides remain supported. ### PR 2 — Realtime compiler: route-first resolution for non-HTML paths ✅ Implemented @@ -373,10 +362,9 @@ Implementation notes (branch `v3/non-html-pages-sitemap-inclusion-policy`): - Implemented exactly per D3 (see the D3 "Implemented" note for the front matter semantics and the `Redirect` refinement). `showInSitemap()` joined the - `BaseHydePageUnitTest` contract; the InMemoryPage unit test covers the - exact-path non-HTML default that the static-extension tests cannot. -- The non-HTML self-exclusion is verified end-to-end: a registered `robots.txt` - `InMemoryPage` is built by the real `build` command and asserted absent from + `BaseHydePageUnitTest` contract. +- The non-HTML self-exclusion is verified end-to-end: a registered `.txt` + `InMemoryPage` subclass is built by the real `build` command and asserted absent from the built `sitemap.xml`, guarding the D3 resolved-output-path constraint against regression by construction rather than only at the unit level. - Two existing tests asserted the leak as expected behavior and were flipped: @@ -409,8 +397,8 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed bound). D4's whole swappability tier is a lie if `app(SitemapGenerator::class)` can't be rebound. Add a test that rebinds the generator and asserts the page's compiled output changes. -- Ensure all first-party generated files (`sitemap.xml`, `feed.xml`, `robots.txt`, - `llms.txt`) use the D2 exact-path mode, including the configurable RSS filename. +- Ensure first-party generated page classes declare their output format per D2; + preserve the configurable RSS filename through a class-level path override. - Register in `HydeCoreExtension::discoverPages()` behind `Features::hasSitemap()` / `Features::hasRss()`; remove `GenerateSitemap`/`GenerateRssFeed` from `BuildTaskService::registerFrameworkTasks()` (evaluate deprecation vs. removal — @@ -479,8 +467,8 @@ Implementation notes, part B (branch `v3/non-html-pages-convert-rss-feed`): 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`). Since the removed task wrote any configured filename - verbatim, `RssFeedPage` uses the exact-path construction mode — `feed.rss` and an - extensionless name therefore work without an inference override. + verbatim, `RssFeedPage` overrides static `outputPath()` — `feed.rss` and an + extensionless name therefore work while static and instance resolution still agree. - `build:rss` builds the registered route's page like `build:sitemap`, and fails with the generic "feature is not enabled" error when the route is not registered (see the revised-in-review notes in the part A section — the old task's no-guard @@ -536,7 +524,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` uses the D2 exact-path mode for `robots.txt`. +- `RobotsTxtPage` declares its `.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. @@ -659,8 +647,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` uses the D2 exact-path - mode, and the generated page self-excludes from its own listing (and the sitemap) +- 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) through the D3 resolved-output-path default. > **Scope correction (post-implementation review).** The first cut of this PR was From dc4b6684534115b1e23812d3591c3481eab78cdf Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 20:40:52 +0200 Subject: [PATCH 4/7] Test static and instance output path symmetry --- .../framework/tests/Feature/DocumentationSearchIndexTest.php | 1 + packages/framework/tests/Feature/LlmsTxtPageTest.php | 1 + packages/framework/tests/Feature/RobotsTxtPageTest.php | 1 + packages/framework/tests/Feature/RssFeedPageTest.php | 2 ++ packages/framework/tests/Feature/SitemapPageTest.php | 1 + 5 files changed, 6 insertions(+) diff --git a/packages/framework/tests/Feature/DocumentationSearchIndexTest.php b/packages/framework/tests/Feature/DocumentationSearchIndexTest.php index 5c8a3d4b373..bc48fb20013 100644 --- a/packages/framework/tests/Feature/DocumentationSearchIndexTest.php +++ b/packages/framework/tests/Feature/DocumentationSearchIndexTest.php @@ -45,6 +45,7 @@ public function testRouteKeyIsSetToVersionedDocumentationOutputDirectory() $this->assertSame('docs/1.x/search.json', $page->routeKey); $this->assertSame('docs/1.x/search.json', $page->getOutputPath()); + $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('1.x', $page->getDocumentationVersion()->name); } diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index 55f722ee813..370f35f1fdf 100644 --- a/packages/framework/tests/Feature/LlmsTxtPageTest.php +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -48,6 +48,7 @@ public function testLlmsTxtPageIsRegisteredAsRouteByDefault() $this->assertInstanceOf(LlmsTxtPage::class, $page); $this->assertSame('llms.txt', $page->getOutputPath()); + $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('llms.txt', $page->getRouteKey()); } diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php index f7b9435e05c..7198273048a 100644 --- a/packages/framework/tests/Feature/RobotsTxtPageTest.php +++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php @@ -41,6 +41,7 @@ public function testRobotsTxtPageIsRegisteredAsRouteByDefault() $this->assertInstanceOf(RobotsTxtPage::class, $page); $this->assertSame('robots.txt', $page->getOutputPath()); + $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('robots.txt', $page->getRouteKey()); } diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php index a8efed9e178..817ee965fb4 100644 --- a/packages/framework/tests/Feature/RssFeedPageTest.php +++ b/packages/framework/tests/Feature/RssFeedPageTest.php @@ -51,6 +51,7 @@ public function testFeedPageIsRegisteredAsRouteWhenRssFeatureIsEnabled() $this->assertInstanceOf(RssFeedPage::class, $page); $this->assertSame('feed.xml', $page->getOutputPath()); + $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('feed.xml', $page->getRouteKey()); } @@ -90,6 +91,7 @@ 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')); } public function testFeedPageIsHiddenFromNavigationAndExcludesItselfFromTheSitemap() diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php index 8180aef9173..24b5b2272d1 100644 --- a/packages/framework/tests/Feature/SitemapPageTest.php +++ b/packages/framework/tests/Feature/SitemapPageTest.php @@ -44,6 +44,7 @@ public function testSitemapPageIsRegisteredAsRouteWhenSitemapFeatureIsEnabled() $this->assertInstanceOf(SitemapPage::class, $page); $this->assertSame('sitemap.xml', $page->getOutputPath()); + $this->assertSame($page::outputPath($page->getIdentifier()), $page->getOutputPath()); $this->assertSame('sitemap.xml', $page->getRouteKey()); } From 8a3e8e9b2dcf41102cda0fc3e4404f6829b1d48c Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 23:06:14 +0200 Subject: [PATCH 5/7] Infer in-memory output paths from identifiers --- .../DocumentationSearchIndex.php | 2 -- .../Features/TextGenerators/LlmsTxtPage.php | 2 -- .../Features/TextGenerators/RobotsTxtPage.php | 2 -- .../Features/XmlGenerators/RssFeedPage.php | 9 --------- .../Features/XmlGenerators/SitemapPage.php | 2 -- packages/framework/src/Pages/InMemoryPage.php | 16 ++++++++++++++++ .../Commands/BuildRssFeedCommandTest.php | 5 +---- .../Commands/BuildSitemapCommandTest.php | 10 ++-------- .../tests/Feature/LlmsTxtPageTest.php | 10 ++-------- .../tests/Feature/NonHtmlPageOutputTest.php | 19 +++++++------------ .../framework/tests/Feature/RedirectTest.php | 4 ++-- .../tests/Feature/RobotsTxtPageTest.php | 10 ++-------- .../tests/Feature/RssFeedPageTest.php | 10 ++-------- .../Feature/Services/SitemapServiceTest.php | 10 ++-------- .../tests/Feature/SitemapPageTest.php | 10 ++-------- .../tests/Unit/Pages/InMemoryPageTest.php | 19 ++++++++++--------- 16 files changed, 48 insertions(+), 92 deletions(-) diff --git a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php index f55bbd6c5b2..bde991b7e74 100644 --- a/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php +++ b/packages/framework/src/Framework/Features/Documentation/DocumentationSearchIndex.php @@ -18,8 +18,6 @@ */ class DocumentationSearchIndex extends InMemoryPage { - public static string $outputExtension = '.json'; - protected readonly ?DocumentationVersion $version; public function __construct(?DocumentationVersion $version = null) diff --git a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php index 77949003d61..9d10ec260f9 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php +++ b/packages/framework/src/Framework/Features/TextGenerators/LlmsTxtPage.php @@ -17,8 +17,6 @@ */ class LlmsTxtPage extends InMemoryPage { - public static string $outputExtension = '.txt'; - public function __construct() { parent::__construct(static::routeKey(), [ diff --git a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php index 5dbe8040b70..62653a35f5c 100644 --- a/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php +++ b/packages/framework/src/Framework/Features/TextGenerators/RobotsTxtPage.php @@ -17,8 +17,6 @@ */ class RobotsTxtPage extends InMemoryPage { - public static string $outputExtension = '.txt'; - public function __construct() { parent::__construct(static::routeKey(), [ diff --git a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php index a1488ad0f6d..32d2315d936 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/RssFeedPage.php @@ -7,7 +7,6 @@ use Hyde\Pages\InMemoryPage; use function app; -use function Hyde\unslash; /** * @internal This page is used to render the RSS feed for the site. @@ -37,12 +36,4 @@ public static function routeKey(): string { return RssFeedGenerator::getFilename(); } - - /** - * Use the configured RSS filename as the output path. - */ - public static function outputPath(string $identifier): string - { - return unslash($identifier); - } } diff --git a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php index ffc4a38ef77..075e79cac23 100644 --- a/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php +++ b/packages/framework/src/Framework/Features/XmlGenerators/SitemapPage.php @@ -17,8 +17,6 @@ */ class SitemapPage extends InMemoryPage { - public static string $outputExtension = '.xml'; - public function __construct() { parent::__construct(static::routeKey(), [ diff --git a/packages/framework/src/Pages/InMemoryPage.php b/packages/framework/src/Pages/InMemoryPage.php index 5b050f9d03d..434a31bf63f 100644 --- a/packages/framework/src/Pages/InMemoryPage.php +++ b/packages/framework/src/Pages/InMemoryPage.php @@ -11,6 +11,8 @@ use Hyde\Pages\Concerns\HydePage; use Illuminate\Support\Facades\View; +use function Hyde\unslash; +use function pathinfo; use function sprintf; use function str_ends_with; @@ -56,6 +58,7 @@ public static function make(string $identifier = '', FrontMatter|array $matter = * * @param string $identifier The identifier of the page. This is used to generate the route key which is used to create the output filename. * If the identifier for an in-memory page is "foo/bar" the page will be saved to "_site/foo/bar.html". + * If the identifier already has an extension, such as "robots.txt", it is used as the output path unchanged. * You can then also use the route helper to get a link to it by using the route key "foo/bar". * Take note that the identifier must be unique to prevent overwriting other pages. * @param \Hyde\Markdown\Models\FrontMatter|array $matter The front matter of the page. When using the Blade view rendering option, @@ -71,6 +74,19 @@ public function __construct(string $identifier = '', FrontMatter|array $matter = $this->view = $view; } + /** + * Qualify a page identifier into a target output file path. + * + * Identifiers with a file extension are used verbatim, while identifiers + * without an extension are compiled to HTML files. + */ + public static function outputPath(string $identifier): string + { + $identifier = unslash($identifier); + + return $identifier.(pathinfo($identifier, PATHINFO_EXTENSION) === '' ? '.html' : ''); + } + /** Get the contents of the page. This will be saved as-is to the output file when this strategy is used. */ public function getContents(): string { diff --git a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php index 0321cca5541..6c567804a7c 100644 --- a/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildRssFeedCommandTest.php @@ -88,10 +88,7 @@ public function testCommandBuildsUserDefinedFeedPageEvenWhenRssFeatureConditions $this->cleanUpWhenDone('_site/feed.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('feed.xml', contents: '') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $kernel->pages()->addPage(InMemoryPage::make('feed.xml', contents: '')); }); $this->artisan('build:rss')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php index 6c10895029d..940f9342b04 100644 --- a/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php +++ b/packages/framework/tests/Feature/Commands/BuildSitemapCommandTest.php @@ -60,10 +60,7 @@ public function testCommandBuildsUserDefinedSitemapPageWhenOneIsRegistered() $this->cleanUpWhenDone('_site/sitemap.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('sitemap.xml', contents: '') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $kernel->pages()->addPage(InMemoryPage::make('sitemap.xml', contents: '')); }); $this->artisan('build:sitemap')->assertExitCode(0); @@ -79,10 +76,7 @@ public function testCommandBuildsUserDefinedSitemapPageEvenWhenSitemapFeatureIsD $this->cleanUpWhenDone('_site/sitemap.xml'); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('sitemap.xml', contents: '') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $kernel->pages()->addPage(InMemoryPage::make('sitemap.xml', contents: '')); }); $this->artisan('build:sitemap')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/LlmsTxtPageTest.php b/packages/framework/tests/Feature/LlmsTxtPageTest.php index 370f35f1fdf..faa3529dfe3 100644 --- a/packages/framework/tests/Feature/LlmsTxtPageTest.php +++ b/packages/framework/tests/Feature/LlmsTxtPageTest.php @@ -129,10 +129,7 @@ public function testLlmsTxtRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedLlmsTxtPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('llms.txt', contents: 'user defined llms') extends InMemoryPage - { - public static string $outputExtension = '.txt'; - }); + $kernel->pages()->addPage(InMemoryPage::make('llms.txt', contents: 'user defined llms')); }); $page = Routes::get('llms.txt')->getPage(); @@ -164,9 +161,6 @@ class LlmsTxtPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new class('llms.txt', contents: 'extension defined llms') extends InMemoryPage - { - public static string $outputExtension = '.txt'; - }); + $collection->addPage(InMemoryPage::make('llms.txt', contents: 'extension defined llms')); } } diff --git a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php index 25e25c0b7d8..de720d66926 100644 --- a/packages/framework/tests/Feature/NonHtmlPageOutputTest.php +++ b/packages/framework/tests/Feature/NonHtmlPageOutputTest.php @@ -30,10 +30,10 @@ protected function tearDown(): void parent::tearDown(); } - public function testBuildCommandCompilesInMemoryPageSubclassWithTxtOutputExtension() + public function testBuildCommandCompilesInMemoryPageWithTxtIdentifier() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: "User-agent: *\nAllow: /")); + $kernel->pages()->addPage(InMemoryPage::make('robots.txt', contents: "User-agent: *\nAllow: /")); }); $this->artisan('build')->assertExitCode(0); @@ -46,7 +46,7 @@ public function testBuildCommandCompilesInMemoryPageSubclassWithTxtOutputExtensi public function testBuildCommandCompilesNestedNonHtmlOutputPath() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryTxtTestPage('foo/bar', contents: 'baz')); + $kernel->pages()->addPage(InMemoryPage::make('foo/bar.txt', contents: 'baz')); }); $this->artisan('build')->assertExitCode(0); @@ -58,7 +58,7 @@ public function testBuildCommandCompilesNestedNonHtmlOutputPath() public function testNonHtmlInMemoryPageIsRegisteredAsRoute() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: 'User-agent: *')); + $kernel->pages()->addPage(InMemoryPage::make('robots.txt', contents: 'User-agent: *')); }); $this->assertTrue(Routes::exists('robots.txt')); @@ -67,7 +67,7 @@ public function testNonHtmlInMemoryPageIsRegisteredAsRoute() public function testStaticPageBuilderCompilesNonHtmlInMemoryPage() { - StaticPageBuilder::handle(new InMemoryTxtTestPage('llms', contents: '# Hello World')); + StaticPageBuilder::handle(InMemoryPage::make('llms.txt', contents: '# Hello World')); $this->assertFileExists(Hyde::path('_site/llms.txt')); $this->assertSame('# Hello World', file_get_contents(Hyde::path('_site/llms.txt'))); @@ -77,7 +77,7 @@ public function testNonHtmlInMemoryPageCanCompileUsingView() { $this->file('_pages/robots.blade.php', 'User-agent: {{ $agent }}'); - StaticPageBuilder::handle(new InMemoryTxtTestPage('robots', ['agent' => '*'], view: 'robots')); + StaticPageBuilder::handle(InMemoryPage::make('robots.txt', ['agent' => '*'], view: 'robots')); $this->assertFileExists(Hyde::path('_site/robots.txt')); $this->assertSame('User-agent: *', file_get_contents(Hyde::path('_site/robots.txt'))); @@ -88,7 +88,7 @@ public function testBuildCommandExcludesNonHtmlInMemoryPageFromSitemap() $this->withSiteUrl(); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new InMemoryTxtTestPage('robots', contents: 'User-agent: *')); + $kernel->pages()->addPage(InMemoryPage::make('robots.txt', contents: 'User-agent: *')); }); $this->artisan('build')->assertExitCode(0); @@ -118,11 +118,6 @@ public function testBuildCommandCompilesDiscoverableCustomPageClassWithNonHtmlOu } } -class InMemoryTxtTestPage extends InMemoryPage -{ - public static string $outputExtension = '.txt'; -} - class DiscoverableNonHtmlTestPage extends HydePage { public static string $sourceDirectory = '_leaves'; diff --git a/packages/framework/tests/Feature/RedirectTest.php b/packages/framework/tests/Feature/RedirectTest.php index 46b8a67c7d7..ee051044bb8 100644 --- a/packages/framework/tests/Feature/RedirectTest.php +++ b/packages/framework/tests/Feature/RedirectTest.php @@ -64,12 +64,12 @@ public function testConfiguredRedirectsAreRegisteredWithTheKernelAndBuiltWithThe Filesystem::unlink('_site/foo.html'); } - public function testDottedRedirectPathUsesHtmlPageSemantics() + public function testDottedRedirectPathKeepsItsExtension() { $redirect = new Redirect('legacy.json', 'new-location'); $this->assertSame('legacy.json', $redirect->getRouteKey()); - $this->assertSame('legacy.json.html', $redirect->getOutputPath()); + $this->assertSame('legacy.json', $redirect->getOutputPath()); } public function testRedirectsCannotWriteOutsideTheBuildPipeline() diff --git a/packages/framework/tests/Feature/RobotsTxtPageTest.php b/packages/framework/tests/Feature/RobotsTxtPageTest.php index 7198273048a..3cde1aa6da8 100644 --- a/packages/framework/tests/Feature/RobotsTxtPageTest.php +++ b/packages/framework/tests/Feature/RobotsTxtPageTest.php @@ -128,10 +128,7 @@ public function testRobotsTxtRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedRobotsTxtPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('robots.txt', contents: 'user defined robots') extends InMemoryPage - { - public static string $outputExtension = '.txt'; - }); + $kernel->pages()->addPage(InMemoryPage::make('robots.txt', contents: 'user defined robots')); }); $page = Routes::get('robots.txt')->getPage(); @@ -163,9 +160,6 @@ class RobotsTxtPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new class('robots.txt', contents: 'extension defined robots') extends InMemoryPage - { - public static string $outputExtension = '.txt'; - }); + $collection->addPage(InMemoryPage::make('robots.txt', contents: 'extension defined robots')); } } diff --git a/packages/framework/tests/Feature/RssFeedPageTest.php b/packages/framework/tests/Feature/RssFeedPageTest.php index 817ee965fb4..f72764c2774 100644 --- a/packages/framework/tests/Feature/RssFeedPageTest.php +++ b/packages/framework/tests/Feature/RssFeedPageTest.php @@ -165,10 +165,7 @@ public function testFeedRouteIsIncludedInTheRouteList() public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedFeedPage() { Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('feed.xml', contents: 'user defined feed') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $kernel->pages()->addPage(InMemoryPage::make('feed.xml', contents: 'user defined feed')); }); $page = Routes::get('feed.xml')->getPage(); @@ -200,9 +197,6 @@ class RssFeedPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new class('feed.xml', contents: 'extension defined feed') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $collection->addPage(InMemoryPage::make('feed.xml', contents: 'extension defined feed')); } } diff --git a/packages/framework/tests/Feature/Services/SitemapServiceTest.php b/packages/framework/tests/Feature/Services/SitemapServiceTest.php index b52951d4fa2..8c02c5afe1d 100644 --- a/packages/framework/tests/Feature/Services/SitemapServiceTest.php +++ b/packages/framework/tests/Feature/Services/SitemapServiceTest.php @@ -135,10 +135,7 @@ public function testGenerateDoesNotAddPagesWithSitemapFrontMatterSetToQuotedFals public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() { - Routes::addRoute(new Route(new class('robots.txt') extends InMemoryPage - { - public static string $outputExtension = '.txt'; - })); + Routes::addRoute(new Route(InMemoryPage::make('robots.txt'))); $service = new SitemapGenerator(); $service->generate(); @@ -149,10 +146,7 @@ public function testGenerateDoesNotAddPagesWithNonHtmlOutputPaths() public function testGenerateAddsNonHtmlPagesWithSitemapFrontMatterSetToTrue() { - Routes::addRoute(new Route(new class('robots.txt', ['sitemap' => true]) extends InMemoryPage - { - public static string $outputExtension = '.txt'; - })); + Routes::addRoute(new Route(InMemoryPage::make('robots.txt', ['sitemap' => true]))); $service = new SitemapGenerator(); $service->generate(); diff --git a/packages/framework/tests/Feature/SitemapPageTest.php b/packages/framework/tests/Feature/SitemapPageTest.php index 24b5b2272d1..1e264f9585d 100644 --- a/packages/framework/tests/Feature/SitemapPageTest.php +++ b/packages/framework/tests/Feature/SitemapPageTest.php @@ -142,10 +142,7 @@ public function testUserPageRegisteredInBootingCallbackSuppressesTheGeneratedSit $this->withSiteUrl(); Hyde::kernel()->booting(function (HydeKernel $kernel): void { - $kernel->pages()->addPage(new class('sitemap.xml', contents: 'user defined sitemap') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $kernel->pages()->addPage(InMemoryPage::make('sitemap.xml', contents: 'user defined sitemap')); }); $page = Routes::get('sitemap.xml')->getPage(); @@ -179,9 +176,6 @@ class SitemapPageTestExtension extends HydeExtension { public function discoverPages(PageCollection $collection): void { - $collection->addPage(new class('sitemap.xml', contents: 'extension defined sitemap') extends InMemoryPage - { - public static string $outputExtension = '.xml'; - }); + $collection->addPage(InMemoryPage::make('sitemap.xml', contents: 'extension defined sitemap')); } } diff --git a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php index c430008c437..d1c5eb30cae 100644 --- a/packages/framework/tests/Unit/Pages/InMemoryPageTest.php +++ b/packages/framework/tests/Unit/Pages/InMemoryPageTest.php @@ -144,21 +144,22 @@ public function testHasMacro() $this->assertFalse($page->hasMacro('bar')); } - public function testOutputPathUsesNormalHtmlPageSemantics() + public function testOutputPathInfersFormatFromIdentifier() { $this->assertSame('foo.html', InMemoryPage::outputPath('foo')); - $this->assertSame('robots.txt.html', InMemoryPage::outputPath('robots.txt')); - $this->assertSame('data.json.html', InMemoryPage::outputPath('data.json')); - $this->assertSame('sitemap.xml.html', InMemoryPage::outputPath('sitemap.xml')); - $this->assertSame('docs/search.json.html', InMemoryPage::outputPath('docs/search.json')); - $this->assertSame('foo.md.html', InMemoryPage::outputPath('foo.md')); - $this->assertSame('foo.html.html', InMemoryPage::outputPath('foo.html')); - $this->assertSame('docs/1.x.html', InMemoryPage::outputPath('docs/1.x')); + $this->assertSame('robots.txt', InMemoryPage::outputPath('robots.txt')); + $this->assertSame('data.json', InMemoryPage::outputPath('data.json')); + $this->assertSame('sitemap.xml', InMemoryPage::outputPath('sitemap.xml')); + $this->assertSame('docs/search.json', InMemoryPage::outputPath('docs/search.json')); + $this->assertSame('foo.md', InMemoryPage::outputPath('foo.md')); + $this->assertSame('foo.html', InMemoryPage::outputPath('foo.html')); + $this->assertSame('docs/1.x', InMemoryPage::outputPath('docs/1.x')); + $this->assertSame('docs/1.x/index.html', InMemoryPage::outputPath('docs/1.x/index')); } public function testStaticAndInstanceOutputPathsUseTheSameSemantics() { - foreach (['foo', 'robots.txt', 'docs/search.json'] as $identifier) { + foreach (['foo', 'robots.txt', 'docs/search.json', 'docs/1.x/index'] as $identifier) { $this->assertSame( InMemoryPage::outputPath($identifier), (new InMemoryPage($identifier))->getOutputPath() From 0691be43c1a16db18fd37b2f87ab8691db420a10 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 23:06:42 +0200 Subject: [PATCH 6/7] Document inferred in-memory output formats --- EPIC_NON_HTML_PAGES.md | 91 +++++++++++------------ HYDEPHP_V3_PLANNING.md | 4 +- UPGRADE.md | 24 +++--- docs/advanced-features/hyde-pages.md | 10 +-- docs/advanced-features/in-memory-pages.md | 17 ++--- 5 files changed, 65 insertions(+), 81 deletions(-) diff --git a/EPIC_NON_HTML_PAGES.md b/EPIC_NON_HTML_PAGES.md index e5878be2c1d..6097e3dca58 100644 --- a/EPIC_NON_HTML_PAGES.md +++ b/EPIC_NON_HTML_PAGES.md @@ -76,36 +76,34 @@ and `docs/search.json` (index) coexist as distinct routes, which they already do > D1-compliant out of the box and PR 2 can rely on "route key == request path with > only `.html` stripped" universally. -### D2: Output extensions are declared by the page class, not inferred - -Do **not** infer "this identifier has an extension" from a dot in the identifier — -versioned docs route keys like `docs/1.x/index` would false-positive -(`pathinfo('docs/1.x')['extension'] === 'x'`). Instead, output intent is explicit: - -- `HydePage` gets `public static string $outputExtension = '.html'`, and both route-key - and output-path resolution use it. -- File-discovered and in-memory custom page classes declare another extension by - redeclaring that static property, mirroring the existing source path properties. -- A plain `InMemoryPage` always uses its class's HTML semantics. Dots in an identifier - carry no output-format meaning. - -> **Final design decision (before PR 8): output behavior remains class-level.** Two -> instance-level designs were implemented during development and rejected before -> release. The first inferred non-HTML behavior from an allowlist of identifier -> suffixes; it merely moved the trap because `.txt` worked while `.webmanifest` did -> not. The second added `InMemoryPage::file()` and an `$exactOutputPath` instance flag. -> It supported arbitrary paths, but broke the `HydePage` static/instance contract: -> `InMemoryPage::outputPath('robots.txt')` returned `robots.txt.html` while the file -> instance's `getOutputPath()` returned `robots.txt`. Compiler integrations are -> allowed to resolve paths statically, so the disagreement was unsafe. -> -> Both designs were removed. Non-HTML behavior is represented by a page subclass with -> a static `$outputExtension`, keeping `Page::outputPath($identifier)` equal to the -> corresponding instance's `getOutputPath()`. First-party generated pages follow the -> same rule. `RssFeedPage` is the narrow exception because its configured filename may -> use any extension or none; it overrides the static `outputPath()` method, so its -> static and instance resolution still agree. Redirects and plain `InMemoryPage` -> instances retain HTML semantics even when their identifiers contain dots. +### D2: In-memory output formats are inferred from the identifier + +`InMemoryPage::outputPath()` uses `pathinfo($identifier, PATHINFO_EXTENSION)` directly. +When the result is empty it appends `.html`; otherwise it keeps the identifier unchanged. +The inherited instance `getOutputPath()` calls this same static method, so static and +instance path resolution cannot disagree. + +This final design followed three rejected approaches: + +1. **Extension allowlist inference.** Treating a fixed set such as `.txt`, `.xml`, and + `.json` as files made those formats convenient but merely moved the ambiguity. An + identifier ending in `.webmanifest` or any future format unexpectedly became HTML. +2. **`InMemoryPage::file()` plus `$exactOutputPath`.** The explicit instance flag + supported arbitrary filenames, but violated the static/instance path contract: + `InMemoryPage::outputPath('robots.txt')` produced `robots.txt.html` while the flagged + instance's `getOutputPath()` produced `robots.txt`. Compiler integrations may resolve + output paths statically, so that mismatch was unsafe. +3. **`$outputExtension` subclasses.** A class-level extension restored static/instance + symmetry, but the developer experience was poor: every one-off file required a + boilerplate subclass, callers had to omit the extension from the identifier, and a + configurable RSS filename still needed its own path override. + +Pure `pathinfo` inference keeps arbitrary extensions, needs no flag or subclass, and +preserves symmetry. The earlier concern that versioned documentation paths would be +false positives was based on a misunderstanding: `pathinfo` examines the basename, so +`docs/1.x/index` has no extension and correctly becomes `docs/1.x/index.html`. While +`docs/1.x` itself has extension `x`, that path is not a valid route key for the version's +root index; the valid identifier ends in `index`. ### D3: Sitemap inclusion becomes a page-level concern @@ -249,9 +247,9 @@ container → fully custom page in code. First-class non-HTML support is about a page's output path and participation in the route/build/serve lifecycle; it does not require a dedicated source-backed page class -for each file extension. A small `InMemoryPage` subclass declaring `$outputExtension` -provides the full lifecycle integration and is a better fit for dynamic content, while -the generated robots and llms pages cover the common cases without source files. +for each file extension. A plain `InMemoryPage` whose identifier includes the desired +extension provides the full lifecycle integration and is a better fit for dynamic +content, while the generated robots and llms pages cover the common cases without source files. A core `TextPage` would add only the convenience of autodiscovering `_pages/*.txt`, while creating pressure for parallel `XmlPage`, `JsonPage`, and similar classes. @@ -271,8 +269,8 @@ Goal: any page class can emit a non-`.html` file without overriding `getOutputPa - Add `$outputExtension` (default `'.html'`) to `HydePage`; use it in `outputPath()` (`HydePage.php:211-214`). - Route keys follow D1; audit `RouteKey` and `Route` for assumptions. -- Keep `InMemoryPage` on the same class-level output path contract as other pages. -- Refactor `DocumentationSearchIndex` to declare its `.json` output extension. +- Override `InMemoryPage::outputPath()` to infer the output suffix from the identifier. +- Keep `DocumentationSearchIndex`'s `.json` extension in its identifier. - Pure refactor for existing sites: no compiled-output changes. Implementation notes (branch `v3/non-html-pages-foundation`): @@ -292,9 +290,11 @@ Implementation notes (branch `v3/non-html-pages-foundation`): "Upgrade script rules" for the release-time Rector script. - Page-class output extension handling was placed in `RouteKey::fromPage()` (see D1 note) rather than only in `outputPath()`, so route keys and output paths cannot drift. -- **Revised before PR 8:** both identifier-suffix inference and the later - instance-level exact-path factory were removed (see D2). Output behavior is - class-level for discovered and in-memory pages alike. +- **Revised before PR 8, then finalized in the subsequent design pivot:** the + allowlist-based inference and later instance-level exact-path factory were removed. + A class-level extension approach briefly replaced them, but was also rejected for + in-memory pages due to its boilerplate and poor call-site ergonomics. The final + implementation uses unrestricted `pathinfo` inference (see D2). - Sitemap / non-HTML detection reads the resolved output path, not just the static extension declaration, so specialized static path overrides remain supported. @@ -363,9 +363,9 @@ Implementation notes (branch `v3/non-html-pages-sitemap-inclusion-policy`): - Implemented exactly per D3 (see the D3 "Implemented" note for the front matter semantics and the `Redirect` refinement). `showInSitemap()` joined the `BaseHydePageUnitTest` contract. -- The non-HTML self-exclusion is verified end-to-end: a registered `.txt` - `InMemoryPage` subclass is built by the real `build` command and asserted absent from - the built `sitemap.xml`, guarding the D3 resolved-output-path constraint +- The non-HTML self-exclusion is verified end-to-end: an `InMemoryPage` with a `.txt` + identifier is built by the real `build` command and asserted absent from the built + `sitemap.xml`, guarding the D3 resolved-output-path constraint against regression by construction rather than only at the unit level. - Two existing tests asserted the leak as expected behavior and were flipped: `SitemapServiceTest` now asserts the docs search *page* stays while the search @@ -397,8 +397,8 @@ Goal: `sitemap.xml` and `feed.xml` are routes — served by `hyde serve`, listed bound). D4's whole swappability tier is a lie if `app(SitemapGenerator::class)` can't be rebound. Add a test that rebinds the generator and asserts the page's compiled output changes. -- Ensure first-party generated page classes declare their output format per D2; - preserve the configurable RSS filename through a class-level path override. +- Ensure first-party generated page identifiers include their output extension per D2; + the configurable RSS filename then uses the shared inference without an override. - Register in `HydeCoreExtension::discoverPages()` behind `Features::hasSitemap()` / `Features::hasRss()`; remove `GenerateSitemap`/`GenerateRssFeed` from `BuildTaskService::registerFrameworkTasks()` (evaluate deprecation vs. removal — @@ -466,9 +466,8 @@ Implementation notes, part B (branch `v3/non-html-pages-convert-rss-feed`): `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`). Since the removed task wrote any configured filename - verbatim, `RssFeedPage` overrides static `outputPath()` — `feed.rss` and an - extensionless name therefore work while static and instance resolution still agree. + (config `hyde.rss.filename`). The shared `InMemoryPage` inference keeps configured + filenames with extensions verbatim and gives extensionless identifiers HTML output. - `build:rss` builds the registered route's page like `build:sitemap`, and fails with the generic "feature is not enabled" error when the route is not registered (see the revised-in-review notes in the part A section — the old task's no-guard diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 52226cce4c3..80df700d147 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -23,7 +23,7 @@ Having this document in code lets us know the devlopment state at any given poin - Added native support for versioned documentation pages. Register versions in the new `docs.versions` configuration option, and store the pages for each version in a matching subdirectory of the documentation source directory (like `_docs/1.x` and `_docs/2.x`). Each version is compiled to a matching subdirectory of the documentation output directory, and gets its own sidebar, search index, and search page. A version switcher dropdown is shown in the documentation sidebar, the main navigation links to the default version's index page, and a redirect page is generated at the documentation root pointing to the default version. Sidebar and search configuration entries (`docs.sidebar.order`, `docs.sidebar.labels`, `docs.sidebar.exclude`, and `docs.exclude_from_search`) match version-agnostic identifiers and route keys, so a single entry applies to the page in every version, while full versioned keys allow version-specific overrides. Enabling the feature is all or nothing: documentation source files stored outside the version directories are ignored, so pages that should live at the documentation root belong in the normal page source directory (like `_pages/docs/index.md`). Versioning is disabled by default, and single-version sites are unaffected. ([#2516](https://github.com/hydephp/develop/pull/2516)) - Redirects can now be declared as source and destination path pairs in the `hyde.redirects` configuration array. Hyde registers them with the kernel, includes them in `route:list`, and generates them through the normal site build. - 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)) -- Custom page classes can compile to non-HTML files by setting the new static `$outputExtension` property (defaulting to `.html`). 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. +- `InMemoryPage` now infers its output format from the identifier: identifiers with an extension keep it, while identifiers without one compile to `.html`. Only the HTML extension is implicit in route keys, so non-HTML paths such as `robots.txt` and `docs/search.json` remain route keys as-is. - 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 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. - Hyde now generates a `robots.txt` file for the site out of the box. The default output allows all crawlers, and links to the sitemap when that feature is enabled. Rule values listed in the new `hyde.robots.disallow` configuration array are written verbatim as `Disallow` rules (so wildcard patterns are supported), and the file can be disabled entirely with `hyde.robots.enabled`. The page is wired like the sitemap and RSS feed: it is a real route (served by `hyde serve`, listed in `route:list`, included in the build manifest), the output can be customized by rebinding the `RobotsTxtGenerator` class in the service container, and a user-defined `robots.txt` page replaces the generated one entirely. @@ -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()`. The old name was ambiguous now that page classes also declare an output extension through the new `$outputExtension` property, and the renamed pair makes the source/output distinction explicit. 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). 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. - 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 3b289bdffcd..1576c678d44 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -2,16 +2,16 @@ ## Overview -HydePHP v3 lets custom page classes declare their output extension. `InMemoryPage` generates HTML by default; extend it -and set the static `$outputExtension` property when creating an in-memory page for another format: +HydePHP v3 infers an `InMemoryPage` output format from its identifier. Identifiers that already have an extension keep +it, while identifiers without an extension compile to `.html`: ```php -class TextFilePage extends InMemoryPage -{ - public static string $outputExtension = '.txt'; -} +use Hyde\Pages\InMemoryPage; + +InMemoryPage::make('about', contents: $html); +// _site/about.html -TextFilePage::make('robots', contents: $text); +InMemoryPage::make('robots.txt', contents: $text); // _site/robots.txt ``` @@ -205,13 +205,8 @@ The same works for `RssFeedGenerator`. use Hyde\Hyde; use Hyde\Pages\InMemoryPage; -class CustomSitemapPage extends InMemoryPage -{ - public static string $outputExtension = '.xml'; -} - Hyde::kernel()->booting(function ($kernel) use ($myXml): void { - $kernel->pages()->addPage(new CustomSitemapPage('sitemap', contents: $myXml)); + $kernel->pages()->addPage(InMemoryPage::make('sitemap.xml', contents: $myXml)); }); ``` @@ -257,8 +252,7 @@ format by registering your own page. The static page class property `$fileExtension` has been renamed to `$sourceExtension`, along with the `fileExtension()` and `setFileExtension()` methods, which are now `sourceExtension()` and `setSourceExtension()`. -The rename pairs the source extension with the new `$outputExtension` property (defaulting to `.html`), which -page classes can override to compile to non-HTML output files. +The new name makes it explicit that these APIs describe the extension of source files. This only affects projects with custom page classes or code calling these APIs. Update property declarations, call sites, and any methods that override `fileExtension()` or `setFileExtension()` — the methods are public diff --git a/docs/advanced-features/hyde-pages.md b/docs/advanced-features/hyde-pages.md index a49602deecc..19c459dfd50 100644 --- a/docs/advanced-features/hyde-pages.md +++ b/docs/advanced-features/hyde-pages.md @@ -161,15 +161,11 @@ autodiscovery, you may benefit from creating a custom page class instead, as tha You can learn more about the InMemoryPage class in the [InMemoryPage documentation](in-memory-pages). -In-memory pages generate HTML by default. Custom subclasses can generate another format by declaring their output extension: +In-memory pages infer their output format from the identifier. Identifiers without an extension compile to `.html`, +while identifiers that already have an extension keep it: ```php -class TextFilePage extends InMemoryPage -{ - public static string $outputExtension = '.txt'; -} - -TextFilePage::make('robots', contents: $text); +InMemoryPage::make('robots.txt', contents: $text); // _site/robots.txt ``` diff --git a/docs/advanced-features/in-memory-pages.md b/docs/advanced-features/in-memory-pages.md index c288f91a59b..44597e6dfe1 100644 --- a/docs/advanced-features/in-memory-pages.md +++ b/docs/advanced-features/in-memory-pages.md @@ -40,26 +40,21 @@ InMemoryPage::make('about', contents: $html); // _site/about.html ``` -In-memory pages generate HTML by default. To generate another format, extend `InMemoryPage` and declare the output -extension on the page class: +The output format is inferred from the identifier. Identifiers without an extension compile to `.html`, while an +identifier that already has an extension keeps it: ```php -class TextFilePage extends InMemoryPage -{ - public static string $outputExtension = '.txt'; -} - -TextFilePage::make('robots', contents: $text); +InMemoryPage::make('robots.txt', contents: $text); // _site/robots.txt ``` -The output extension belongs to the page type, ensuring static and instance path resolution use the same rules: +Static and instance path resolution use the same inference: ```php -TextFilePage::outputPath('robots'); +InMemoryPage::outputPath('robots.txt'); // robots.txt -TextFilePage::make('robots')->getOutputPath(); +InMemoryPage::make('robots.txt')->getOutputPath(); // robots.txt ``` From d9b070e26bf44f9bcd7e56db9843f6fcfa3653ea Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 14 Jul 2026 23:20:17 +0200 Subject: [PATCH 7/7] Build documentation --- .../hyde-pages-api/in-memory-page-methods.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md index fb5ea70654c..249c6a9cf32 100644 --- a/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md +++ b/docs/_data/partials/hyde-pages-api/in-memory-page-methods.md @@ -1,7 +1,7 @@
- + #### `make()` @@ -11,6 +11,16 @@ Static alias for the constructor. InMemoryPage::make(string $identifier, Hyde\Markdown\Models\FrontMatter|array $matter, string $contents, string $view): static ``` +#### `outputPath()` + +Qualify a page identifier into a target output file path. + +Identifiers with a file extension are used verbatim, while identifiers without an extension are compiled to HTML files. + +```php +InMemoryPage::outputPath(string $identifier): string +``` + #### `__construct()` Create a new in-memory/virtual page instance. @@ -19,7 +29,7 @@ The in-memory page class offers two content options. You can either pass a strin Note that $contents take precedence over $view, so if you pass both, only $contents will be used. You can also register a macro with the name 'compile' to overload the default compile method. -If the identifier for an in-memory page is "foo/bar" the page will be saved to "_site/foo/bar.html". You can then also use the route helper to get a link to it by using the route key "foo/bar". Take note that the identifier must be unique to prevent overwriting other pages. all this data will be passed to the view rendering engine. +If the identifier for an in-memory page is "foo/bar" the page will be saved to "_site/foo/bar.html". If the identifier already has an extension, such as "robots.txt", it is used as the output path unchanged. You can then also use the route helper to get a link to it by using the route key "foo/bar". Take note that the identifier must be unique to prevent overwriting other pages. all this data will be passed to the view rendering engine. - **Parameter $view:** The view key or Blade file for the view to use to render the page contents. - **Parameter $matter:** The front matter of the page. When using the Blade view rendering option,