From 8bb60342b7378dd9e86e163ab04b6be9cffde84b Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 11 Aug 2026 04:48:50 +0200 Subject: [PATCH 1/4] Support custom media directories in the realtime compiler The serve command resolves the configured media directories and passes them to the server process, so media requests keep the fast path that proxies the file without booting the application. Co-Authored-By: Claude Opus 5 --- .../src/Actions/AssetFileLocator.php | 25 ++++++++++++++++--- .../src/Console/Commands/ServeCommand.php | 2 ++ .../src/Http/DashboardController.php | 2 +- .../realtime-compiler/src/Routing/Router.php | 2 +- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/realtime-compiler/src/Actions/AssetFileLocator.php b/packages/realtime-compiler/src/Actions/AssetFileLocator.php index 714ac9e9c94..2a53833f583 100644 --- a/packages/realtime-compiler/src/Actions/AssetFileLocator.php +++ b/packages/realtime-compiler/src/Actions/AssetFileLocator.php @@ -19,9 +19,8 @@ public static function find(string $path): ?string return $static; } - // TODO: Custom media directories are unsupported because media is proxied before the application boots. - if (str_starts_with($path, 'media/')) { - $media = BASE_PATH.'/_media/'.substr($path, strlen('media/')); + if (static::isMediaPath($path)) { + $media = BASE_PATH.'/'.static::mediaDirectory().'/'.substr($path, strlen(static::mediaOutputDirectory()) + 1); if (is_file($media)) { return $media; @@ -30,4 +29,24 @@ public static function find(string $path): ?string return null; } + + public static function isMediaPath(string $path): bool + { + return str_starts_with(trim($path, '/'), static::mediaOutputDirectory().'/'); + } + + /** + * The serve command resolves the configured media directories and passes them to the server + * process, as media is proxied before the application boots. The defaults apply when the + * server is started directly, for example through the Herd integration. + */ + protected static function mediaDirectory(): string + { + return getenv('HYDE_SERVER_MEDIA_DIRECTORY') ?: '_media'; + } + + protected static function mediaOutputDirectory(): string + { + return getenv('HYDE_SERVER_MEDIA_OUTPUT_DIRECTORY') ?: 'media'; + } } diff --git a/packages/realtime-compiler/src/Console/Commands/ServeCommand.php b/packages/realtime-compiler/src/Console/Commands/ServeCommand.php index 8b583284a8c..19167bb9120 100644 --- a/packages/realtime-compiler/src/Console/Commands/ServeCommand.php +++ b/packages/realtime-compiler/src/Console/Commands/ServeCommand.php @@ -106,6 +106,8 @@ protected function getEnvironmentVariables(): array 'HYDE_SERVER_DASHBOARD' => $this->parseEnvironmentOption('dashboard'), 'HYDE_PRETTY_URLS' => $this->parseEnvironmentOption('pretty-urls'), 'HYDE_PLAY_CDN' => $this->parseEnvironmentOption('play-cdn'), + 'HYDE_SERVER_MEDIA_DIRECTORY' => Hyde::getMediaDirectory(), + 'HYDE_SERVER_MEDIA_OUTPUT_DIRECTORY' => Hyde::getMediaOutputDirectory(), ]); } diff --git a/packages/realtime-compiler/src/Http/DashboardController.php b/packages/realtime-compiler/src/Http/DashboardController.php index 64f5ce2c55b..4882f6b3e05 100644 --- a/packages/realtime-compiler/src/Http/DashboardController.php +++ b/packages/realtime-compiler/src/Http/DashboardController.php @@ -149,7 +149,7 @@ public function getRoutePreviewLink(Route $route): string public function getMediaPreviewLink(MediaFile $mediaFile): string { - return $this->rootRelativeLink('media/'.$mediaFile->getIdentifier()); + return $this->rootRelativeLink(Hyde::getMediaOutputDirectory().'/'.$mediaFile->getIdentifier()); } /** @return array{label: string, mark: string, color: string, rgb: string} */ diff --git a/packages/realtime-compiler/src/Routing/Router.php b/packages/realtime-compiler/src/Routing/Router.php index 601b43588dd..732441591e8 100644 --- a/packages/realtime-compiler/src/Routing/Router.php +++ b/packages/realtime-compiler/src/Routing/Router.php @@ -28,7 +28,7 @@ public function handle(): Response { // Media files are always static assets, so we proxy them // directly without paying for booting the application. - if (str_starts_with($this->request->path, '/media/')) { + if (AssetFileLocator::isMediaPath($this->request->path)) { return $this->proxyStatic(); } From bb2d4f779def9a150ef6d87ec39458407f07b7f0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 11 Aug 2026 04:48:53 +0200 Subject: [PATCH 2/4] Test that the server serves a custom media directory Co-Authored-By: Claude Opus 5 --- .../Feature/Commands/ServeCommandTest.php | 18 ++++++-- .../Unit/ServeCommandOptionsUnitTest.php | 6 +++ .../tests/RealtimeCompilerTest.php | 44 +++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/framework/tests/Feature/Commands/ServeCommandTest.php b/packages/framework/tests/Feature/Commands/ServeCommandTest.php index c2c38794718..3b91771eaef 100644 --- a/packages/framework/tests/Feature/Commands/ServeCommandTest.php +++ b/packages/framework/tests/Feature/Commands/ServeCommandTest.php @@ -150,7 +150,11 @@ public function testHydeServeCommandPassesThroughProcessOutput() Process::shouldReceive('env') ->once() - ->with(['HYDE_SERVER_REQUEST_OUTPUT' => false]) + ->with([ + 'HYDE_SERVER_REQUEST_OUTPUT' => false, + 'HYDE_SERVER_MEDIA_DIRECTORY' => '_media', + 'HYDE_SERVER_MEDIA_OUTPUT_DIRECTORY' => 'media', + ]) ->andReturnSelf(); Process::shouldReceive('start') @@ -205,7 +209,11 @@ public function testHydeServeCommandWithViteOption() Process::shouldReceive('env') ->once() - ->with(['HYDE_SERVER_REQUEST_OUTPUT' => false]) + ->with([ + 'HYDE_SERVER_REQUEST_OUTPUT' => false, + 'HYDE_SERVER_MEDIA_DIRECTORY' => '_media', + 'HYDE_SERVER_MEDIA_OUTPUT_DIRECTORY' => 'media', + ]) ->andReturnSelf(); Process::shouldReceive('start') @@ -254,7 +262,11 @@ public function testHydeServeCommandWithViteOptionButViteNotRunning() Process::shouldReceive('env') ->once() - ->with(['HYDE_SERVER_REQUEST_OUTPUT' => false]) + ->with([ + 'HYDE_SERVER_REQUEST_OUTPUT' => false, + 'HYDE_SERVER_MEDIA_DIRECTORY' => '_media', + 'HYDE_SERVER_MEDIA_OUTPUT_DIRECTORY' => 'media', + ]) ->andReturnSelf(); Process::shouldReceive('start') diff --git a/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php b/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php index 0f19f45e880..3e9f2de2c35 100644 --- a/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php +++ b/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php @@ -20,6 +20,8 @@ #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\RealtimeCompiler\Console\Commands\ServeCommand::class)] class ServeCommandOptionsUnitTest extends UnitTestCase { + protected static bool $needsKernel = true; + protected function setUp(): void { self::mockConfig([ @@ -88,6 +90,8 @@ public function testGetEnvironmentVariables() { $this->assertSame([ 'HYDE_SERVER_REQUEST_OUTPUT' => true, + 'HYDE_SERVER_MEDIA_DIRECTORY' => '_media', + 'HYDE_SERVER_MEDIA_OUTPUT_DIRECTORY' => 'media', ], $this->getMock()->getEnvironmentVariables()); } @@ -95,6 +99,8 @@ public function testGetEnvironmentVariablesWithNoAnsiOption() { $this->assertSame([ 'HYDE_SERVER_REQUEST_OUTPUT' => false, + 'HYDE_SERVER_MEDIA_DIRECTORY' => '_media', + 'HYDE_SERVER_MEDIA_OUTPUT_DIRECTORY' => 'media', ], $this->getMock(['no-ansi' => true])->getEnvironmentVariables()); } diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index 9070a0a9dbc..2b4c7309030 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -14,6 +14,8 @@ use Hyde\RealtimeCompiler\Http\ExceptionHandler; use Desilva\Microserve\HtmlResponse; use Hyde\RealtimeCompiler\Http\HttpKernel; +use Hyde\RealtimeCompiler\Http\DashboardController; +use Hyde\Support\Filesystem\MediaFile; use Hyde\RealtimeCompiler\Routing\PageRouter; use Hyde\RealtimeCompiler\Routing\Router; @@ -128,6 +130,48 @@ public function testNormalizesMediaPath() Filesystem::unlink('_media/test.css'); } + public function testHandlesRoutesStaticAssetsInCustomMediaDirectory() + { + putenv('HYDE_SERVER_MEDIA_DIRECTORY=_custom-media'); + putenv('HYDE_SERVER_MEDIA_OUTPUT_DIRECTORY=custom-media'); + + $this->mockCompilerRoute('custom-media/test.css'); + Filesystem::ensureDirectoryExists('_custom-media'); + Filesystem::put('_custom-media/test.css', 'test'); + + try { + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertSame(200, $response->statusCode); + $this->assertSame('test', $response->body); + } finally { + putenv('HYDE_SERVER_MEDIA_DIRECTORY'); + putenv('HYDE_SERVER_MEDIA_OUTPUT_DIRECTORY'); + + Filesystem::deleteDirectory('_custom-media'); + } + } + + public function testDashboardMediaPreviewLinksUseTheConfiguredMediaOutputDirectory() + { + $this->mockCompilerRoute('dashboard'); + + Hyde::setMediaDirectory('_custom-media'); + Filesystem::ensureDirectoryExists('_custom-media'); + Filesystem::put('_custom-media/test.css', 'test'); + + try { + $dashboard = new DashboardController(new Request()); + + $this->assertSame('/custom-media/test.css', $dashboard->getMediaPreviewLink(MediaFile::make('test.css'))); + } finally { + Hyde::setMediaDirectory('_media'); + + Filesystem::deleteDirectory('_custom-media'); + } + } + public function testStaticDirectoryTakesPrecedenceForMediaPath(): void { $this->mockCompilerRoute('media/static.jpg'); From 3023ad362b82da8f39568e0fd65e8c7bb9280881 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 11 Aug 2026 04:48:53 +0200 Subject: [PATCH 3/4] Add release note for custom media directory support in the dev server Co-Authored-By: Claude Opus 5 --- HYDEPHP_V3_PLANNING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 9555eb19fbc..eed8207afc1 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -46,6 +46,7 @@ Having this document in code lets us know the devlopment state at any given poin - Fixed documentation search index files leaking into the generated sitemap: `search.json` (and any other page compiled to a non-HTML output file) no longer appears in `sitemap.xml`. The sitemap generator now asks each page through `HydePage::showInSitemap()` instead of only filtering out redirect pages. - The `Redirect` page class constructor now accepts an optional `$matter` parameter, used by the framework to hide the generated documentation root redirect from navigation menus. Existing usages are unaffected. - The realtime compiler now resolves registered page routes before proxying static assets, replacing the hardcoded `search.json` exemption, so `hyde serve` serves any registered route regardless of its output extension. Registered pages now always win over a static file at the same path; the previous behavior of serving such a shadowing file only affected the dev server and no real setups are expected to be affected. +- `hyde serve` now serves media files from a custom media directory. In v2 the dev server only knew the default `_media` to `media` convention, so a project using the `media_directory` config option got 404s for all its media while previewing the site, even though the built site was correct. The serve command now passes the resolved directories to the server process, which keeps media requests on the fast path that skips booting the application. - Removed `Hyde\Markdown\Processing\CodeblockFilepathProcessor`, along with the `` marker comments it passed between its own pre- and post-processing steps. Both were internal implementation details: the processor list is hardcoded in an internal trait, so there was no supported way to register the class, and the markers only ever existed part-way through a single conversion. Neither is documented in the changelog or upgrade guide for that reason. Labels are now resolved on the syntax tree by `PrepareCodeBlocks`. - Changed the generated HTML for fenced code blocks, which now comes from the Blade view. Site output is not part of the backward compatibility promise, so this is noted for awareness rather than as a breaking change. The `hyde-code-block` and `hyde-code-block-label` classes are stable hooks for projects styling code blocks from their own CSS. From 550cb38ffd0906eaf915d4a93234533ddbcdc6c0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 11 Aug 2026 13:10:18 +0200 Subject: [PATCH 4/4] Test that a custom media directory propagates to the server environment Co-Authored-By: Claude Opus 5 --- .../tests/Unit/ServeCommandOptionsUnitTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php b/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php index 3e9f2de2c35..d1c7bb3f683 100644 --- a/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php +++ b/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php @@ -5,6 +5,7 @@ namespace Hyde\Framework\Testing\Unit; use Mockery; +use Hyde\Hyde; use Hyde\Testing\UnitTestCase; use Hyde\Foundation\HydeKernel; use Illuminate\Process\Factory; @@ -104,6 +105,20 @@ public function testGetEnvironmentVariablesWithNoAnsiOption() ], $this->getMock(['no-ansi' => true])->getEnvironmentVariables()); } + public function testGetEnvironmentVariablesWithCustomMediaDirectory() + { + Hyde::setMediaDirectory('_custom-media'); + + try { + $environment = $this->getMock()->getEnvironmentVariables(); + + $this->assertSame('_custom-media', $environment['HYDE_SERVER_MEDIA_DIRECTORY']); + $this->assertSame('custom-media', $environment['HYDE_SERVER_MEDIA_OUTPUT_DIRECTORY']); + } finally { + Hyde::setMediaDirectory('_media'); + } + } + public function testSavePreviewOptionPropagatesToEnvironmentVariables() { $command = $this->getMock(['save-preview' => 'false']);