From 5a3dd969ae6c5e53534b02b477f857cd605b9955 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 10 Aug 2026 17:06:00 +0200 Subject: [PATCH 1/3] Add root static file passthrough --- .../src/Console/Commands/BuildSiteCommand.php | 3 + .../Framework/Actions/TransferStaticFiles.php | 78 +++++++++++++++++++ .../src/Actions/AssetFileLocator.php | 17 +++- .../realtime-compiler/src/Routing/Router.php | 3 +- 4 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 packages/framework/src/Framework/Actions/TransferStaticFiles.php diff --git a/packages/framework/src/Console/Commands/BuildSiteCommand.php b/packages/framework/src/Console/Commands/BuildSiteCommand.php index 0f0fadfe443..b7d9eee337a 100644 --- a/packages/framework/src/Console/Commands/BuildSiteCommand.php +++ b/packages/framework/src/Console/Commands/BuildSiteCommand.php @@ -8,6 +8,7 @@ use Hyde\Facades\Vite; use Hyde\Facades\Config; use Hyde\Support\BuildWarnings; +use Hyde\Framework\Actions\TransferStaticFiles; use Hyde\Console\Concerns\Command; use Hyde\Framework\Services\BuildService; use Hyde\Framework\Services\BuildTaskService; @@ -59,6 +60,8 @@ public function handle(): int Vite::forceDisable(false); } + TransferStaticFiles::handle(); + $this->runPostBuildActions(); $this->printFinishMessage($timeStart); diff --git a/packages/framework/src/Framework/Actions/TransferStaticFiles.php b/packages/framework/src/Framework/Actions/TransferStaticFiles.php new file mode 100644 index 00000000000..4373a41626b --- /dev/null +++ b/packages/framework/src/Framework/Actions/TransferStaticFiles.php @@ -0,0 +1,78 @@ +map(function (SplFileInfo $file): array { + $sourcePath = Hyde::pathToRelative($file->getPathname()); + + return [ + 'source' => $file->getPathname(), + 'output' => Hyde::sitePath(substr($sourcePath, strlen('_static/'))), + ]; + }); + + $currentBuildOutputPaths = static::currentBuildOutputPaths(); + + $files->each(function (array $file) use ($currentBuildOutputPaths): void { + if ($currentBuildOutputPaths->contains($file['output']) || Filesystem::isDirectory($file['output'])) { + throw new FileConflictException($file['output']); + } + }); + + $files->each(function (array $file): void { + static::needsParentDirectory($file['output']); + Filesystem::copy($file['source'], $file['output']); + }); + } + + /** @return \Illuminate\Support\Collection */ + protected static function findStaticFiles(): Collection + { + if (! Filesystem::isDirectory('_static')) { + return collect(); + } + + return collect(Filesystem::allFiles(Hyde::path('_static'), true)); + } + + /** @return \Illuminate\Support\Collection */ + protected static function currentBuildOutputPaths(): Collection + { + $paths = Hyde::pages()->map(fn (HydePage $page): string => Hyde::sitePath($page->getOutputPath()))->values(); + + if (Config::getBool('hyde.transfer_media_assets', true)) { + $paths = $paths->concat(MediaFile::all()->map(fn (MediaFile $file): string => $file->getOutputPath())); + } + + if (Config::getBool('hyde.generate_build_manifest', true)) { + $paths->push(Hyde::path(Config::getString( + 'hyde.build_manifest_path', + 'app/storage/framework/cache/build-manifest.json' + ))); + } + + return $paths; + } +} diff --git a/packages/realtime-compiler/src/Actions/AssetFileLocator.php b/packages/realtime-compiler/src/Actions/AssetFileLocator.php index 5805306c26d..714ac9e9c94 100644 --- a/packages/realtime-compiler/src/Actions/AssetFileLocator.php +++ b/packages/realtime-compiler/src/Actions/AssetFileLocator.php @@ -13,8 +13,21 @@ public static function find(string $path): ?string { $path = trim($path, '/'); - $file = BASE_PATH.'/_media/'.str_replace('media/', '', $path); + $static = BASE_PATH.'/_static/'.$path; - return file_exists($file) ? $file : null; + if (is_file($static)) { + 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 (is_file($media)) { + return $media; + } + } + + return null; } } diff --git a/packages/realtime-compiler/src/Routing/Router.php b/packages/realtime-compiler/src/Routing/Router.php index 4638194660b..601b43588dd 100644 --- a/packages/realtime-compiler/src/Routing/Router.php +++ b/packages/realtime-compiler/src/Routing/Router.php @@ -45,7 +45,8 @@ public function handle(): Response // A path with a file extension that isn't a web page is a static asset request, // unless a page route is registered for the path (like `docs/search.json`), // as pages take precedence over the on-disk files the proxy serves. - if ($this->hasAssetLikeExtension() && ! PageRouter::hasRoute($this->request)) { + if (! PageRouter::hasRoute($this->request) + && ($this->hasAssetLikeExtension() || AssetFileLocator::find($this->request->path) !== null)) { return $this->proxyStatic(); } From dd7cda147925b0cced92d8f51d0fd4863e9017c7 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 10 Aug 2026 17:08:21 +0200 Subject: [PATCH 2/3] Test static file passthrough --- .../Feature/StaticFilePassthroughTest.php | 105 ++++++++++++++++++ .../tests/RealtimeCompilerTest.php | 53 +++++++-- 2 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 packages/framework/tests/Feature/StaticFilePassthroughTest.php diff --git a/packages/framework/tests/Feature/StaticFilePassthroughTest.php b/packages/framework/tests/Feature/StaticFilePassthroughTest.php new file mode 100644 index 00000000000..9b856e5c59a --- /dev/null +++ b/packages/framework/tests/Feature/StaticFilePassthroughTest.php @@ -0,0 +1,105 @@ +file('_static/robots.txt', "User-agent: *\nAllow: /"); + $this->file('_static/.well-known/security.txt', 'Contact: mailto:security@example.com'); + $this->file('_static/favicon.ico', $binary); + + $this->artisan('build')->assertExitCode(0); + + $this->assertSame("User-agent: *\nAllow: /", Filesystem::getContents('_site/robots.txt')); + $this->assertSame('Contact: mailto:security@example.com', Filesystem::getContents('_site/.well-known/security.txt')); + $this->assertSame($binary, Filesystem::getContents('_site/favicon.ico')); + } + + public function testAbsentStaticDirectoryIsIgnored(): void + { + $this->artisan('build')->assertExitCode(0); + + $this->assertDirectoryDoesNotExist(Hyde::path('_static')); + } + + public function testStaticFilesOverwriteTheirOutputFromThePreviousBuild(): void + { + $this->file('_static/robots.txt', 'first'); + $this->artisan('build')->assertExitCode(0); + + $this->file('_static/robots.txt', 'second'); + $this->artisan('build')->assertExitCode(0); + + $this->assertSame('second', Filesystem::getContents('_site/robots.txt')); + } + + public function testStaticFilesCannotOverwriteGeneratedOutput(): void + { + $this->file('_static/a.txt', 'copied first without preflight'); + $this->file('_static/index.html', 'static replacement'); + + try { + $this->artisan('build')->run(); + $this->fail('The conflicting static file was not rejected.'); + } catch (FileConflictException $exception) { + $this->assertSame('File [_site/index.html] already exists.', $exception->getMessage()); + } + + $this->assertFileDoesNotExist(Hyde::sitePath('a.txt')); + $this->assertStringNotContainsString('static replacement', Filesystem::getContents('_site/index.html')); + } + + public function testStaticFilesCannotOverwriteTransferredMedia(): void + { + $this->file('_media/static-collision.jpg', 'media'); + $this->file('_static/a.txt', 'copied first without preflight'); + $this->file('_static/media/static-collision.jpg', 'static'); + + try { + $this->artisan('build')->run(); + $this->fail('The conflicting static file was not rejected.'); + } catch (FileConflictException $exception) { + $this->assertSame('File [_site/media/static-collision.jpg] already exists.', $exception->getMessage()); + } finally { + Filesystem::unlink('_media/static-collision.jpg'); + } + + $this->assertFileDoesNotExist(Hyde::sitePath('a.txt')); + $this->assertSame('media', Filesystem::getContents('_site/media/static-collision.jpg')); + } + + public function testDisabledMediaTransferDoesNotCreateAStaticFileCollision(): void + { + config(['hyde.transfer_media_assets' => false]); + $this->file('_media/static-collision.jpg', 'media'); + $this->file('_static/media/static-collision.jpg', 'static'); + + try { + $this->artisan('build')->assertExitCode(0); + $this->assertSame('static', Filesystem::getContents('_site/media/static-collision.jpg')); + } finally { + Filesystem::unlink('_media/static-collision.jpg'); + } + } +} diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index 8c538ec3715..9070a0a9dbc 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -43,6 +43,7 @@ protected function setUp(): void protected function tearDown(): void { $_SERVER = $this->serverBackup; + Filesystem::deleteDirectory('_static'); parent::tearDown(); ob_end_clean(); @@ -127,6 +128,24 @@ public function testNormalizesMediaPath() Filesystem::unlink('_media/test.css'); } + public function testStaticDirectoryTakesPrecedenceForMediaPath(): void + { + $this->mockCompilerRoute('media/static.jpg'); + Filesystem::ensureDirectoryExists('_static/media'); + Filesystem::put('_media/static.jpg', 'media'); + Filesystem::put('_static/media/static.jpg', 'static media'); + + try { + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertSame(200, $response->statusCode); + $this->assertSame('static media', $response->body); + } finally { + Filesystem::unlink('_media/static.jpg'); + } + } + public function testThrowsRouteNotFoundExceptionForMissingRoute() { $this->mockCompilerRoute('missing'); @@ -221,8 +240,9 @@ public function testServesRegisteredPageRouteEvenWhenMatchingAssetExists() $this->mockCompilerRoute('9.x'); Filesystem::ensureDirectoryExists('_pages/9.x'); + Filesystem::ensureDirectoryExists('_static'); Filesystem::put('_pages/9.x/index.md', '# Hello World!'); - Filesystem::put('_media/9.x', 'static decoy'); + Filesystem::put('_static/9.x', 'static decoy'); try { $kernel = new HttpKernel(); @@ -233,7 +253,7 @@ public function testServesRegisteredPageRouteEvenWhenMatchingAssetExists() $this->assertStringNotContainsString('static decoy', $response->body); } finally { Filesystem::deleteDirectory('_pages/9.x'); - Filesystem::unlink('_media/9.x'); + Filesystem::unlink('_static/9.x'); } } @@ -242,8 +262,8 @@ public function testDocsSearchJsonRouteWinsOverMatchingAssetFile() $this->mockCompilerRoute('docs/search.json'); Filesystem::put('_docs/index.md', '# Hello World!'); - Filesystem::ensureDirectoryExists('_media/docs'); - Filesystem::put('_media/docs/search.json', '"static decoy"'); + Filesystem::ensureDirectoryExists('_static/docs'); + Filesystem::put('_static/docs/search.json', '"static decoy"'); try { $kernel = new HttpKernel(); @@ -254,7 +274,7 @@ public function testDocsSearchJsonRouteWinsOverMatchingAssetFile() $this->assertIsArray(json_decode($response->body, true)); } finally { Filesystem::unlink('_docs/index.md'); - Filesystem::deleteDirectory('_media/docs'); + Filesystem::deleteDirectory('_static/docs'); } } @@ -262,7 +282,8 @@ public function testProxiesRootLevelAssetWhenNoRouteMatchesThePath() { $this->mockCompilerRoute('data.json'); - Filesystem::put('_media/data.json', '{"static": true}'); + Filesystem::ensureDirectoryExists('_static'); + Filesystem::put('_static/data.json', '{"static": true}'); try { $kernel = new HttpKernel(); @@ -271,7 +292,25 @@ public function testProxiesRootLevelAssetWhenNoRouteMatchesThePath() $this->assertSame(200, $response->statusCode); $this->assertSame('{"static": true}', $response->body); } finally { - Filesystem::unlink('_media/data.json'); + Filesystem::unlink('_static/data.json'); + } + } + + public function testProxiesExtensionlessRootStaticFileWhenNoRouteMatchesThePath() + { + $this->mockCompilerRoute('security'); + + Filesystem::ensureDirectoryExists('_static'); + Filesystem::put('_static/security', 'contact@example.com'); + + try { + $kernel = new HttpKernel(); + $response = $kernel->handle(new Request()); + + $this->assertSame(200, $response->statusCode); + $this->assertSame('contact@example.com', $response->body); + } finally { + Filesystem::unlink('_static/security'); } } From 04c2b1365578947304cebfecdb6bb1b3e5aae6e3 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 10 Aug 2026 17:08:47 +0200 Subject: [PATCH 3/3] Document root static files --- HYDEPHP_V3_PLANNING.md | 1 + docs/creating-content/managing-assets.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 74e502956a9..aecb40c2742 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -33,6 +33,7 @@ Having this document in code lets us know the devlopment state at any given poin ### Feature Changes +- Added an optional `_static` source directory for files that should be copied verbatim to the root of the compiled site while preserving relative paths, such as `robots.txt`, `llms.txt`, favicons, and `.well-known` files. The existing `_media` to `_site/media` convention is unchanged. - Fenced code blocks are now rendered through a publishable Blade view, `components/markdown/code-block.blade.php`, in the same way terminal blocks are. The view receives the rendered code block markup as `$contents`, along with `$language` and `$label`, and decides what goes around it, so changing what surrounds a code block is a view change instead of a framework change. Highlighting itself is unaffected: the fence stays in the syntax tree as the wrapper's child, and is rendered by whichever renderer the environment already had for it, be it Torchlight, a third-party extension, or CommonMark's own. - Code block labels are now set with a `title="…"` modifier on the fence, using the same attribute syntax as terminal block titles, such as ` ```php title="app/Model.php" `. The language is optional, so ` ``` title=".env" ` labels a block that declares none, which is then treated as `plaintext`. The label is no longer tied to file paths, so a block can be titled with anything. Labels use a responsive header instead of the v2 top-right badge. The v2 `// filepath:` comment syntax is removed. - Blade in Markdown is now enabled by default. The `markdown.enable_blade` option controls both `[Blade]:` directives and executable Blade Blocks. Hyde sites generally treat project content as trusted and reviewed; sites that compile untrusted or unreviewed Markdown can disable both forms with this option. diff --git a/docs/creating-content/managing-assets.md b/docs/creating-content/managing-assets.md index d1f7faf90ab..08de9dcf872 100644 --- a/docs/creating-content/managing-assets.md +++ b/docs/creating-content/managing-assets.md @@ -16,6 +16,10 @@ Hyde ships with a complete frontend using Blade views, TailwindCSS styles, and A To get you started quickly, all the styles are already compiled and minified into `_media/app.css`, which will be copied to the `_site/media/app.css` directory when you run `php hyde build`. +## Root-Level Static Files + +Files that need to be published directly to the site root can be placed in an optional `_static` directory. Paths are preserved, so `_static/robots.txt` becomes `_site/robots.txt` and `_static/.well-known/security.txt` becomes `_site/.well-known/security.txt`. Use `_media` for normal site assets published under `/media`. + ## Vite Hyde uses [Vite](https://vite.dev/) to compile assets. Vite is a build tool that aims to provide a faster and more efficient development experience for modern web projects.