diff --git a/packages/realtime-compiler/src/Http/VirtualRouteController.php b/packages/realtime-compiler/src/Http/VirtualRouteController.php index 4b6e76ce546..dfc3a5ef7bc 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 { @@ -26,4 +28,22 @@ public static function liveEdit(Request $request): Response { return (new LiveEditController($request))->handle(); } + + public static function sitemap(): Response + { + return (new Response(200, 'OK', [ + 'body' => SitemapGenerator::make(), + ]))->withHeaders([ + 'Content-Type' => 'application/xml', + ]); + } + + public static function rssFeed(): 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 e5c77e18fa7..3854935e324 100644 --- a/packages/realtime-compiler/src/RealtimeCompilerServiceProvider.php +++ b/packages/realtime-compiler/src/RealtimeCompilerServiceProvider.php @@ -38,5 +38,8 @@ public function boot(): void if (LiveEditController::enabled()) { $router->registerVirtualRoute('/_hyde/live-edit', [VirtualRouteController::class, 'liveEdit']); } + + // The sitemap and RSS feed routes are registered in the Router itself, once the site URL + // has been finalized for the request. See Router::registerDynamicVirtualRoutes(). } } diff --git a/packages/realtime-compiler/src/Routing/Router.php b/packages/realtime-compiler/src/Routing/Router.php index bf58946d4b3..15f341ab045 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 { @@ -34,6 +37,8 @@ public function handle(): Response $this->overrideSiteUrl(); + $this->registerDynamicVirtualRoutes(); + $virtualRoutes = app(RealtimeCompiler::class)->getVirtualRoutes(); if (isset($virtualRoutes[$this->request->path])) { @@ -43,6 +48,27 @@ 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()) { + $compiler->registerVirtualRoute('/'.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. @@ -69,6 +95,19 @@ protected function shouldProxy(Request $request): 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 ($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 as the application + // is not booted yet, so we match against the default filename instead. + if ($request->path === '/feed.xml') { + return false; + } + // The page is not a web page, so we assume it should be proxied. return true; } 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 01b6e81d8fc..d95aaf14251 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -256,6 +256,43 @@ 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 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');