diff --git a/packages/realtime-compiler/src/Http/VirtualRouteController.php b/packages/realtime-compiler/src/Http/VirtualRouteController.php index ac67e75247e..e000daec940 100644 --- a/packages/realtime-compiler/src/Http/VirtualRouteController.php +++ b/packages/realtime-compiler/src/Http/VirtualRouteController.php @@ -7,6 +7,8 @@ use Desilva\Microserve\Request; use Desilva\Microserve\Response; use Desilva\Microserve\JsonResponse; +use Hyde\Framework\Features\XmlGenerators\SitemapGenerator; +use Hyde\Framework\Features\XmlGenerators\RssFeedGenerator; class VirtualRouteController { @@ -31,4 +33,22 @@ public static function openInEditor(Request $request): Response { return (new OpenInEditorController($request))->handle(); } + + public static function sitemap(Request $request): Response + { + return (new Response(200, 'OK', [ + 'body' => SitemapGenerator::make(), + ]))->withHeaders([ + 'Content-Type' => 'application/xml', + ]); + } + + public static function rssFeed(Request $request): Response + { + return (new Response(200, 'OK', [ + 'body' => RssFeedGenerator::make(), + ]))->withHeaders([ + 'Content-Type' => 'application/rss+xml', + ]); + } } diff --git a/packages/realtime-compiler/src/RealtimeCompilerServiceProvider.php b/packages/realtime-compiler/src/RealtimeCompilerServiceProvider.php index 49780f70cb4..30fd1f7c4ad 100644 --- a/packages/realtime-compiler/src/RealtimeCompilerServiceProvider.php +++ b/packages/realtime-compiler/src/RealtimeCompilerServiceProvider.php @@ -43,5 +43,8 @@ public function boot(): void if (OpenInEditorController::enabled()) { $router->registerVirtualRoute('/_hyde/open-in-editor', [VirtualRouteController::class, 'openInEditor']); } + + // The sitemap and RSS feed routes are registered dynamically instead of here. + // @see \Hyde\RealtimeCompiler\Routing\Router::registerDynamicVirtualRoutes() } } diff --git a/packages/realtime-compiler/src/Routing/Router.php b/packages/realtime-compiler/src/Routing/Router.php index 315e6bf3eaf..5d626bcf9c4 100644 --- a/packages/realtime-compiler/src/Routing/Router.php +++ b/packages/realtime-compiler/src/Routing/Router.php @@ -6,11 +6,14 @@ use Desilva\Microserve\Request; use Desilva\Microserve\Response; +use Hyde\Facades\Features; use Hyde\RealtimeCompiler\RealtimeCompiler; use Hyde\RealtimeCompiler\Actions\AssetFileLocator; use Hyde\RealtimeCompiler\Concerns\SendsErrorResponses; +use Hyde\RealtimeCompiler\Http\VirtualRouteController; use Hyde\RealtimeCompiler\Models\FileObject; use Hyde\RealtimeCompiler\Concerns\InteractsWithLaravel; +use Hyde\Framework\Features\XmlGenerators\RssFeedGenerator; class Router { @@ -37,6 +40,8 @@ public function handle(): Response $this->overrideSiteUrl(); + $this->registerDynamicVirtualRoutes(); + $virtualRoutes = app(RealtimeCompiler::class)->getVirtualRoutes(); if (isset($virtualRoutes[$this->request->path])) { @@ -53,6 +58,30 @@ public function handle(): Response return PageRouter::handle($this->request); } + /** + * Register virtual routes whose availability depends on the site URL, which is only + * finalized after {@see overrideSiteUrl()} has run. Unlike the routes registered in + * the service provider's boot method, these can't be resolved any earlier: outside of + * `save_preview` mode, the site URL is always overridden to a local address, so (unlike + * a real `hyde build`) we don't need a production site URL to be configured to serve + * these on the local dev server. + */ + protected function registerDynamicVirtualRoutes(): void + { + $compiler = app(RealtimeCompiler::class); + + if (Features::hasSitemap()) { + $compiler->registerVirtualRoute('/sitemap.xml', [VirtualRouteController::class, 'sitemap']); + } + + if (Features::hasRss()) { + // Note: this correctly registers a custom `hyde.rss.filename`, even though + // `shouldProxy()` below can't recognize that filename and will still proxy + // it as a static asset request. That's an accepted, intentional asymmetry. + $compiler->registerVirtualRoute('/'.ltrim(RssFeedGenerator::getFilename(), '/'), [VirtualRouteController::class, 'rssFeed']); + } + } + /** * If the request is not for a web page, we assume it's * a static asset, which we instead want to proxy. @@ -74,6 +103,21 @@ protected function shouldProxy(): bool return false; } + // Don't proxy the sitemap, as it's generated on the fly. + // Note that unlike the RSS feed below, the sitemap filename is not configurable. + if ($this->request->path === '/sitemap.xml') { + return false; + } + + // Don't proxy the RSS feed, as it's generated on the fly. + // We can't resolve the configured `hyde.rss.filename` here without booting the + // app, which we deliberately avoid for performance on every static asset request. + // A customized RSS filename is rare enough that we accept it falling back to + // static-asset proxying rather than being dynamically generated. + if ($this->request->path === '/feed.xml') { + return false; + } + // Dotted page routes (like documentation version folders) are proxied only when a matching asset exists. return $this->resolveAssetPath() !== null; } diff --git a/packages/realtime-compiler/tests/Integration/IntegrationTest.php b/packages/realtime-compiler/tests/Integration/IntegrationTest.php index 302b42ebe7e..f12bacca18f 100644 --- a/packages/realtime-compiler/tests/Integration/IntegrationTest.php +++ b/packages/realtime-compiler/tests/Integration/IntegrationTest.php @@ -86,4 +86,26 @@ public function testDynamicDocumentationSearchPages() unlink($this->projectPath('_docs/index.md')); unlink($this->projectPath('_docs/installation.md')); } + + public function testDynamicSitemapGeneration() + { + // No production site URL needs to be configured: the realtime compiler always + // overrides it with the local server address, which is what we assert against. + $this->get('/sitemap.xml') + ->assertStatus(200) + ->assertHeader('Content-Type', 'application/xml') + ->assertSeeText('http://localhost:8080'); + } + + public function testDynamicRssFeedGeneration() + { + file_put_contents($this->projectPath('_posts/dynamic-rss-test.md'), "---\ntitle: Dynamic RSS Test\ndescription: Dynamic RSS test description\n---\n\n# Dynamic RSS Test"); + + $this->get('/feed.xml') + ->assertStatus(200) + ->assertHeader('Content-Type', 'application/rss+xml') + ->assertSeeText('Dynamic RSS Test'); + + unlink($this->projectPath('_posts/dynamic-rss-test.md')); + } } diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index 31f18636046..2c43a623451 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -14,6 +14,7 @@ use Hyde\RealtimeCompiler\Http\ExceptionHandler; use Desilva\Microserve\HtmlResponse; use Hyde\RealtimeCompiler\Http\HttpKernel; +use Hyde\RealtimeCompiler\RealtimeCompiler; use Hyde\RealtimeCompiler\Routing\PageRouter; use Hyde\RealtimeCompiler\Routing\Router; @@ -323,6 +324,66 @@ public function testGetContentTypeDefaultsToTextHtmlForUnknownExtension() $this->assertSame('text/html', $this->invokeGetContentType($page)); } + public function testSitemapRouteReturnsSitemapResponse() + { + // Note this works even without a production site URL configured: the router always + // overrides the site URL to the local server address (unless save_preview is enabled), + // so the sitemap and RSS feed are available on the dev server regardless of whether a + // production URL has been set. + $this->mockCompilerRoute('sitemap.xml'); + + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->statusCode); + $this->assertSame('OK', $response->statusMessage); + $this->assertStringContainsString('', $response->body); + $this->assertStringContainsString('body); + } + + public function testRegisterDynamicVirtualRoutesDoesNotRegisterSitemapWhenFeatureIsDisabled() + { + $this->mockCompilerRoute('sitemap.xml'); + config(['hyde.url' => 'http://localhost:8080', 'hyde.generate_sitemap' => false]); + + $routes = $this->invokeRegisterDynamicVirtualRoutes(); + + $this->assertArrayNotHasKey('/sitemap.xml', $routes); + } + + public function testRegisterDynamicVirtualRoutesDoesNotRegisterRssFeedWhenFeatureIsDisabled() + { + $this->mockCompilerRoute('feed.xml'); + config(['hyde.url' => 'http://localhost:8080', 'hyde.rss.enabled' => false]); + Filesystem::put('_posts/test-post.md', "---\ntitle: Test Post\ndescription: Test post description\n---\n\n# Test Post"); + + $routes = $this->invokeRegisterDynamicVirtualRoutes(); + + $this->assertArrayNotHasKey('/feed.xml', $routes); + + Filesystem::unlink('_posts/test-post.md'); + } + + public function testRssFeedRouteReturnsRssResponse() + { + $this->mockCompilerRoute('feed.xml'); + Filesystem::put('_posts/test-post.md', "---\ntitle: Test Post\ndescription: Test post description\n---\n\n# Test Post"); + + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->statusCode); + $this->assertSame('OK', $response->statusMessage); + $this->assertStringContainsString('', $response->body); + $this->assertStringContainsString('body); + $this->assertStringContainsString('version="2.0"', $response->body); + $this->assertStringContainsString('Test Post', $response->body); + + Filesystem::unlink('_posts/test-post.md'); + } + public function testPingRouteReturnsPingResponse() { $this->mockCompilerRoute('ping'); @@ -590,6 +651,16 @@ protected function invokeOverrideSiteUrl(): void $method->invoke(new Router(new Request())); } + /** @return array */ + protected function invokeRegisterDynamicVirtualRoutes(): array + { + $method = new ReflectionMethod(Router::class, 'registerDynamicVirtualRoutes'); + $method->setAccessible(true); + $method->invoke(new Router(new Request())); + + return app(RealtimeCompiler::class)->getVirtualRoutes(); + } + protected function invokeGetContentType(InMemoryPage $page): string { $this->mockCompilerRoute('foo');