From cc5dca1b9c79cb599c2339b589b47a20b198cba8 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Fri, 28 Aug 2026 17:13:35 +0200 Subject: [PATCH 01/20] Make the media directory a passthrough for all file types The media directory previously only discovered files matching an extension allow-list (hyde.media_extensions), so unlisted types like PDFs, fonts, and archives were silently ignored. Discovery now finds every file, relying on Symfony Finder's existing dotfile/VCS exclusions, and skips only Thumbs.db and desktop.ini regardless of case. The hyde.media_extensions option and the MediaFile::EXTENSIONS constant are removed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0123tyfadjk86BHng4TjBPBH --- config/hyde.php | 4 --- packages/framework/config/hyde.php | 4 --- .../src/Foundation/Concerns/HasMediaFiles.php | 13 +++++--- .../src/Support/Filesystem/MediaFile.php | 3 -- .../tests/Feature/DiscoveryServiceTest.php | 30 ------------------- .../tests/Feature/StaticSiteServiceTest.php | 6 ++-- .../FilesystemHasMediaFilesTest.php | 23 +++++++++----- 7 files changed, 28 insertions(+), 55 deletions(-) diff --git a/config/hyde.php b/config/hyde.php index b667610c7f5..c2b33122046 100644 --- a/config/hyde.php +++ b/config/hyde.php @@ -483,10 +483,6 @@ | */ - // Change the file extensions to be considered as media files and are copied to the output directory. - // If you want to add more extensions, add it to the empty merge array, or just override the entire array. - 'media_extensions' => array_merge([], \Hyde\Support\Filesystem\MediaFile::EXTENSIONS), - // Should a JSON build manifest with metadata about the build be generated? 'generate_build_manifest' => true, diff --git a/packages/framework/config/hyde.php b/packages/framework/config/hyde.php index b667610c7f5..c2b33122046 100644 --- a/packages/framework/config/hyde.php +++ b/packages/framework/config/hyde.php @@ -483,10 +483,6 @@ | */ - // Change the file extensions to be considered as media files and are copied to the output directory. - // If you want to add more extensions, add it to the empty merge array, or just override the entire array. - 'media_extensions' => array_merge([], \Hyde\Support\Filesystem\MediaFile::EXTENSIONS), - // Should a JSON build manifest with metadata about the build be generated? 'generate_build_manifest' => true, diff --git a/packages/framework/src/Foundation/Concerns/HasMediaFiles.php b/packages/framework/src/Foundation/Concerns/HasMediaFiles.php index ce1d7a1ab31..802db793e85 100644 --- a/packages/framework/src/Foundation/Concerns/HasMediaFiles.php +++ b/packages/framework/src/Foundation/Concerns/HasMediaFiles.php @@ -5,12 +5,14 @@ namespace Hyde\Foundation\Concerns; use Hyde\Hyde; -use Hyde\Facades\Config; use Hyde\Facades\Filesystem; use Hyde\Support\Filesystem\MediaFile; use Illuminate\Support\Collection; use function collect; +use function basename; +use function in_array; +use function strtolower; /** * @internal Single-use trait for the Filesystem class. @@ -19,6 +21,9 @@ */ trait HasMediaFiles { + /** Filenames that are never treated as media files, matched case-insensitively. */ + private const IGNORED_FILENAMES = ['thumbs.db', 'desktop.ini']; + /** @var Collection The Collection keys are the filenames relative to the _media/ directory */ protected Collection $assets; @@ -43,8 +48,8 @@ protected static function discoverMediaFiles(): Collection protected static function getMediaFiles(): array { - return Filesystem::findFiles(Hyde::getMediaDirectory(), - Config::getArray('hyde.media_extensions', MediaFile::EXTENSIONS), recursive: true - )->all(); + return Filesystem::findFiles(Hyde::getMediaDirectory(), false, recursive: true) + ->reject(fn (string $path): bool => in_array(strtolower(basename($path)), self::IGNORED_FILENAMES, true)) + ->values()->all(); } } diff --git a/packages/framework/src/Support/Filesystem/MediaFile.php b/packages/framework/src/Support/Filesystem/MediaFile.php index 888cd91ae39..cc1232bcef1 100644 --- a/packages/framework/src/Support/Filesystem/MediaFile.php +++ b/packages/framework/src/Support/Filesystem/MediaFile.php @@ -24,9 +24,6 @@ */ class MediaFile extends ProjectFile implements Stringable { - /** @var array The default extensions for media types */ - final public const EXTENSIONS = ['png', 'svg', 'jpg', 'jpeg', 'webp', 'gif', 'ico', 'css', 'js']; - protected readonly int $length; protected readonly string $mimeType; protected readonly string $hash; diff --git a/packages/framework/tests/Feature/DiscoveryServiceTest.php b/packages/framework/tests/Feature/DiscoveryServiceTest.php index de5de419a09..3615c704bec 100644 --- a/packages/framework/tests/Feature/DiscoveryServiceTest.php +++ b/packages/framework/tests/Feature/DiscoveryServiceTest.php @@ -103,10 +103,7 @@ public function testGetMediaAssetFilesDiscoversCustomFileTypes() { $path = 'test.custom'; $this->file("_media/$path"); - $this->assertNotContains($path, MediaFile::files()); - self::mockConfig(['hyde.media_extensions' => ['custom']]); - $this->resetKernel(); // Reboot to rediscover new files $this->assertContains($path, MediaFile::files()); } @@ -127,33 +124,6 @@ public function testGetMediaAssetFilesDiscoversFilesVeryRecursively() Filesystem::deleteDirectory('_media/foo'); } - public function testMediaAssetExtensionsCanBeAddedByCommaSeparatedValues() - { - self::mockConfig(['hyde.media_extensions' => []]); - $this->file('_media/test.1'); - $this->file('_media/test.2'); - $this->file('_media/test.3'); - - $this->assertSame([], MediaFile::files()); - - self::mockConfig(['hyde.media_extensions' => ['1,2,3']]); - $this->resetKernel(); // Reboot to rediscover new files - $this->assertSame(['test.1', 'test.2', 'test.3'], MediaFile::files()); - } - - public function testMediaAssetExtensionsCanBeAddedByArray() - { - self::mockConfig(['hyde.media_extensions' => []]); - $this->file('_media/test.1'); - $this->file('_media/test.2'); - $this->file('_media/test.3'); - - $this->assertSame([], MediaFile::files()); - self::mockConfig(['hyde.media_extensions' => ['1', '2', '3']]); - $this->resetKernel(); // Reboot to rediscover new files - $this->assertSame(['test.1', 'test.2', 'test.3'], MediaFile::files()); - } - public function testBladePageFilesStartingWithUnderscoreAreIgnored() { $this->file('_pages/_foo.blade.php'); diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index fd4a21c0071..4cd653272a8 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -96,7 +96,9 @@ public function testBuildCommandCreatesNeededMediaDirectoriesRecursively() public function testBuildCommandSkipsMediaTransferWhenThereAreNoAssets() { - rename(Hyde::path('_media/app.css'), Hyde::path('_media/app.css.bak')); + // Since the media directory is a passthrough, the file must leave the directory + // entirely to make it empty, rather than just being renamed within it. + rename(Hyde::path('_media/app.css'), Hyde::path('app.css.bak')); $this->artisan('build') ->expectsOutputToContain('Transferring Media Assets... ') @@ -104,7 +106,7 @@ public function testBuildCommandSkipsMediaTransferWhenThereAreNoAssets() ->expectsOutputToContain('> No media files to transfer.') ->assertExitCode(0); - rename(Hyde::path('_media/app.css.bak'), Hyde::path('_media/app.css')); + rename(Hyde::path('app.css.bak'), Hyde::path('_media/app.css')); } public function testBuildCommandDoesNotUseViteDevServerPathEvenWhenViteIsRunning() diff --git a/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php b/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php index edfd66b8c01..c29f8be2bd1 100644 --- a/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php +++ b/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php @@ -82,7 +82,7 @@ public function testUsesRecursiveFinderSearch() (new Filesystem(Hyde::getInstance()))->assets(); - $mock->shouldHaveReceived('handle')->with('_media', MediaFile::EXTENSIONS, true); + $mock->shouldHaveReceived('handle')->with('_media', false, true); } public function testItSupportsCustomMediaDirectory() @@ -93,18 +93,25 @@ public function testItSupportsCustomMediaDirectory() (new Filesystem(Hyde::getInstance()))->assets(); - $mock->shouldHaveReceived('handle')->with('assets', MediaFile::EXTENSIONS, true); + $mock->shouldHaveReceived('handle')->with('assets', false, true); } - public function testItSupportsCustomExtensions() + public function testItIgnoresThumbsDbAndDesktopIniRegardlessOfCase() { - self::mockConfig(['hyde.media_extensions' => ['gif', 'svg']]); - - $mock = $this->mockFileFinder(); + $mock = Mockery::mock(FileFinder::class); + $mock->shouldReceive('handle')->andReturn(collect([ + '_media/Thumbs.db', + '_media/thumbs.db', + '_media/Desktop.ini', + '_media/desktop.ini', + '_media/image.png', + ])); + app()->instance(FileFinder::class, $mock); - (new Filesystem(Hyde::getInstance()))->assets(); + $assets = (new Filesystem(Hyde::getInstance()))->assets(); - $mock->shouldHaveReceived('handle')->with('_media', ['gif', 'svg'], true); + $this->assertCount(1, $assets); + $this->assertTrue($assets->has('image.png')); } public function testDiscoverMediaFilesWithEmptyResult() From f130b424284e3fd8390fa8b3e33ed96762af6d09 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Fri, 28 Aug 2026 17:17:23 +0200 Subject: [PATCH 02/20] Stream media file copies instead of loading them into memory TransferMediaAssets loaded every media file's contents into memory to write them back out. That was fine for CSS but the media directory can now contain PDFs and archives of any size, so use Filesystem::copy() to stream the transfer and fail loudly with both paths named if it doesn't succeed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0123tyfadjk86BHng4TjBPBH --- .../PreBuildTasks/TransferMediaAssets.php | 8 +++-- .../tests/Feature/StaticSiteServiceTest.php | 32 +++++++++++++++++++ .../Unit/MediaDirectoryCanBeChangedTest.php | 2 ++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php b/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php index ac6d3783204..957469f3811 100644 --- a/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php +++ b/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php @@ -4,11 +4,12 @@ namespace Hyde\Framework\Actions\PreBuildTasks; +use RuntimeException; use Hyde\Facades\Config; use Hyde\Facades\Filesystem; use Hyde\Support\Filesystem\MediaFile; -use Hyde\Framework\Features\BuildTasks\PreBuildTask; use Hyde\Framework\Concerns\InteractsWithDirectories; +use Hyde\Framework\Features\BuildTasks\PreBuildTask; class TransferMediaAssets extends PreBuildTask { @@ -33,7 +34,10 @@ public function handle(): void $this->withProgressBar($files, function (MediaFile $file): void { $sitePath = $file->getOutputPath(); $this->needsParentDirectory($sitePath); - Filesystem::putContents($sitePath, $file->getContents()); + + if (! Filesystem::copy($file->getPath(), $sitePath)) { + throw new RuntimeException("Failed to copy media file from [{$file->getPath()}] to [{$sitePath}]"); + } }); $this->newLine(); diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index 4cd653272a8..ea930bc01a9 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -94,6 +94,38 @@ public function testBuildCommandCreatesNeededMediaDirectoriesRecursively() $this->assertFileExists(Hyde::path('_site/media/foo/bar/3.png')); } + public function testBuildCommandDiscoversAndTransfersArbitraryFileTypesWithStructurePreserved() + { + $this->file('_media/document.pdf', 'pdf content'); + $this->file('_media/font.woff2', 'font content'); + $this->file('_media/archive.zip', 'zip content'); + $this->file('_media/nested/deep/file.xyz', 'nested content'); + + $this->artisan('build')->assertExitCode(0); + + $this->assertFileEquals(Hyde::path('_media/document.pdf'), Hyde::path('_site/media/document.pdf')); + $this->assertFileEquals(Hyde::path('_media/font.woff2'), Hyde::path('_site/media/font.woff2')); + $this->assertFileEquals(Hyde::path('_media/archive.zip'), Hyde::path('_site/media/archive.zip')); + $this->assertFileEquals(Hyde::path('_media/nested/deep/file.xyz'), Hyde::path('_site/media/nested/deep/file.xyz')); + } + + public function testBuildCommandIgnoresThumbsDbAndHiddenOrVcsFiles() + { + $this->file('_media/Thumbs.db'); + $this->file('_media/.gitkeep'); + $this->file('_media/.DS_Store'); + $this->file('_media/.env'); + $this->file('_media/.git/config'); + + $this->artisan('build')->assertExitCode(0); + + $this->assertFileDoesNotExist(Hyde::path('_site/media/Thumbs.db')); + $this->assertFileDoesNotExist(Hyde::path('_site/media/.gitkeep')); + $this->assertFileDoesNotExist(Hyde::path('_site/media/.DS_Store')); + $this->assertFileDoesNotExist(Hyde::path('_site/media/.env')); + $this->assertDirectoryDoesNotExist(Hyde::path('_site/media/.git')); + } + public function testBuildCommandSkipsMediaTransferWhenThereAreNoAssets() { // Since the media directory is a passthrough, the file must leave the directory diff --git a/packages/framework/tests/Unit/MediaDirectoryCanBeChangedTest.php b/packages/framework/tests/Unit/MediaDirectoryCanBeChangedTest.php index c1dc7be7599..2a083d2f070 100644 --- a/packages/framework/tests/Unit/MediaDirectoryCanBeChangedTest.php +++ b/packages/framework/tests/Unit/MediaDirectoryCanBeChangedTest.php @@ -25,6 +25,7 @@ public function testMediaOutputDirectoryCanBeChangedForSiteBuilds() $this->directory('_assets'); $this->file('_assets/app.css'); + $this->file('_assets/document.pdf'); Hyde::setMediaDirectory('_assets'); @@ -33,6 +34,7 @@ public function testMediaOutputDirectoryCanBeChangedForSiteBuilds() $this->assertDirectoryDoesNotExist(Hyde::path('_site/media')); $this->assertDirectoryExists(Hyde::path('_site/assets')); $this->assertFileExists(Hyde::path('_site/assets/app.css')); + $this->assertFileExists(Hyde::path('_site/assets/document.pdf')); $this->resetSite(); } From 09eeb0a5b0360b770e5b25be4698ea12539c7433 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Fri, 28 Aug 2026 17:17:29 +0200 Subject: [PATCH 03/20] Skip resolving media assets that are not referenced in the HTML DynamicMarkdownLinkProcessor::postprocess() called assetPath() for every discovered media file to build its replacement, even when the file's source path never appeared in the HTML. assetPath() resolves and CRC32-hashes the file, so with the media directory now discovering every file instead of a filtered subset, that was wasted work on a much larger set of files. Skip straight to the next asset when the source path isn't present in the HTML. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0123tyfadjk86BHng4TjBPBH --- .../src/Markdown/Processing/DynamicMarkdownLinkProcessor.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/framework/src/Markdown/Processing/DynamicMarkdownLinkProcessor.php b/packages/framework/src/Markdown/Processing/DynamicMarkdownLinkProcessor.php index c14732b2e09..140281ca7b7 100644 --- a/packages/framework/src/Markdown/Processing/DynamicMarkdownLinkProcessor.php +++ b/packages/framework/src/Markdown/Processing/DynamicMarkdownLinkProcessor.php @@ -26,6 +26,10 @@ public static function postprocess(string $html): string } foreach (static::assetMap() as $sourcePath => $mediaFile) { + if (! str_contains($html, $sourcePath)) { + continue; + } + $patterns = [ sprintf(' Date: Fri, 28 Aug 2026 17:18:25 +0200 Subject: [PATCH 04/20] Document the media directory passthrough change Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0123tyfadjk86BHng4TjBPBH --- CHANGELOG.md | 2 ++ HYDEPHP_V3_PLANNING.md | 3 +++ UPGRADE.md | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c149dcca5..a9efc369dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ This serves two purposes: - Pages with non-HTML output paths are now excluded from automatic navigation by default. Explicit navigation front matter now takes precedence over automatic navigation exclusions. - Fenced code blocks are now rendered through the publishable `components/markdown/code-block.blade.php` view, which changes the generated markup around the code. Syntax highlighting is unaffected, and the `hyde-code-block` and `hyde-code-block-label` classes are stable hooks for your own CSS. - Code block labels are now set with a `title="…"` modifier on the fence, replacing the `// filepath:` comment syntax, which is no longer recognized and must be replaced. +- The `_media` directory is now a passthrough: every file it contains is discovered and copied to the built site, not just files with a recognized extension. `Thumbs.db` and `desktop.ini` are still skipped. ### Deprecated - for changes that will be removed in upcoming releases. @@ -38,6 +39,7 @@ This serves two purposes: - Removed the `rebuild` command. It had no remaining internal consumers now that the realtime compiler renders pages in-memory, and single-page builds can silently leave aggregate outputs (sitemap, RSS, search index, navigation) stale. Use `Hyde\Framework\Actions\StaticPageBuilder::handle()` instead if you need to build a single page programmatically. - Removed the `InMemoryPage` instance macro API. Use a contents closure for dynamic output, or extend `InMemoryPage` to add custom methods and behavior. - Removed the `components/filepath-label.blade.php` view, as the label markup now lives in the code block view. If this was published, port any customizations into `components/markdown/code-block.blade.php`. +- Removed the `hyde.media_extensions` config option, now that the media directory discovers files of every type. ### Fixed - Fixed Markdown-to-plain-text conversion consuming unrelated content after ATX headings and stripping literal trailing hashes in https://github.com/hydephp/develop/pull/2590 diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index eed8207afc1..ccd8cbc0e58 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -74,6 +74,8 @@ Having this document in code lets us know the devlopment state at any given poin The undocumented `hyde.empty_output_directory` option is removed too. It was never present in the shipped `config/hyde.php` and never documented, so no upgrade step is needed for it. +- The `_media` directory is now a passthrough for every file it contains, rather than only files matching the `hyde.media_extensions` allow-list. That option is removed, along with `MediaFile::EXTENSIONS`, with no deprecated compatibility constant. `Thumbs.db` and `desktop.ini` are still skipped regardless of case, since Symfony Finder's dotfile and VCS exclusions were already relied on and remain unchanged. This lets media types Hyde never had an opinion about — PDFs, fonts, archives — pass through without a config change, at the cost of also copying files a project may have parked in `_media` for other reasons. + ### Upgrade guide Please fill in UPGRADE.md as you make changes. @@ -92,6 +94,7 @@ Please fill in UPGRADE.md as you make changes. - Compare a few pages against your old site if you have custom CSS for code blocks or their labels, since the generated markup changed. The `hyde-code-block` and `hyde-code-block-label` classes are stable hooks to target instead of the markup structure. - Port any customizations from a published `filepath-label.blade.php` to `markdown/code-block.blade.php`. The old file is ignored after upgrading, so the site renders with the shipped label until they are moved. - Move manually maintained files out of the output directory and into `_static`, since the whole output directory is now emptied before every build. Remove `safe_output_directories` from a published `config/hyde.php`. +- Remove `media_extensions` from a published `config/hyde.php`, and move any non-asset files out of `_media` that you were relying on the extension allow-list to keep out of the build. ## `InMemoryPage` content-source motivation diff --git a/UPGRADE.md b/UPGRADE.md index d81d138ffe6..eb69cc43fe6 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -439,6 +439,12 @@ Move those files into the `_static` directory, which is copied verbatim to the s The `hyde.safe_output_directories` option no longer exists, and the build no longer asks for confirmation before emptying an unfamiliar output directory. Delete the entry from your `config/hyde.php`. Take the chance to double-check your `hyde.output_directory` if you build somewhere other than `_site`, since everything in that directory is now removed on every build. +## Step 13: Review the Contents of Your Media Directory + +The `hyde.media_extensions` config option no longer exists, and `_media` now discovers every file it contains, not just files with a recognized extension. Delete the entry from your `config/hyde.php`. + +If you kept non-asset files in `_media` relying on the extension allow-list to exclude them from the build, such as design source files or notes, move them out of the directory, since they'll now be copied to the built site. + ## Migration Checklist Use this checklist to track your upgrade progress: @@ -456,6 +462,7 @@ Use this checklist to track your upgrade progress: - [ ] Compared pages against your old site if you have custom CSS for code blocks or their labels - [ ] Checked `_posts` for drafts and blog posts dated in the future, and set up recurring builds if scheduling posts - [ ] Moved manually maintained files out of the output directory and into `_static` +- [ ] Removed `hyde.media_extensions` from `config/hyde.php` and moved any non-asset files out of `_media` ## Troubleshooting From 2cccb637f1cf5b7782e4d70f71b7fbe64d1805b8 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Fri, 28 Aug 2026 17:28:03 +0200 Subject: [PATCH 05/20] Trim redundant comments in the passthrough directory change Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0123tyfadjk86BHng4TjBPBH --- packages/framework/src/Foundation/Concerns/HasMediaFiles.php | 1 - packages/framework/tests/Feature/StaticSiteServiceTest.php | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/framework/src/Foundation/Concerns/HasMediaFiles.php b/packages/framework/src/Foundation/Concerns/HasMediaFiles.php index 802db793e85..1fb58473005 100644 --- a/packages/framework/src/Foundation/Concerns/HasMediaFiles.php +++ b/packages/framework/src/Foundation/Concerns/HasMediaFiles.php @@ -21,7 +21,6 @@ */ trait HasMediaFiles { - /** Filenames that are never treated as media files, matched case-insensitively. */ private const IGNORED_FILENAMES = ['thumbs.db', 'desktop.ini']; /** @var Collection The Collection keys are the filenames relative to the _media/ directory */ diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index ea930bc01a9..59a3f70dddd 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -128,8 +128,7 @@ public function testBuildCommandIgnoresThumbsDbAndHiddenOrVcsFiles() public function testBuildCommandSkipsMediaTransferWhenThereAreNoAssets() { - // Since the media directory is a passthrough, the file must leave the directory - // entirely to make it empty, rather than just being renamed within it. + // The file must leave the media directory entirely, since renaming it within a passthrough directory wouldn't empty it. rename(Hyde::path('_media/app.css'), Hyde::path('app.css.bak')); $this->artisan('build') From 085c3d58f2da1dbef48bad475ab92241469aef97 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Fri, 28 Aug 2026 18:02:17 +0200 Subject: [PATCH 06/20] Attempt every media file before reporting copy failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Throwing inside the withProgressBar closure on the first failed copy aborted the loop, so any remaining media files silently never transferred. Collect failures instead, let the loop finish, and throw a single exception afterward naming every failure with both paths. The exception carries Command::FAILURE as its code so it's correct once BuildTask's exit code actually propagates to the process — a pre-existing gap across all build tasks, out of scope here. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0123tyfadjk86BHng4TjBPBH --- .../PreBuildTasks/TransferMediaAssets.php | 19 ++++++++++++++++--- .../tests/Feature/StaticSiteServiceTest.php | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php b/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php index 957469f3811..6569b019bdb 100644 --- a/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php +++ b/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php @@ -8,8 +8,12 @@ use Hyde\Facades\Config; use Hyde\Facades\Filesystem; use Hyde\Support\Filesystem\MediaFile; -use Hyde\Framework\Concerns\InteractsWithDirectories; +use Symfony\Component\Console\Command\Command; use Hyde\Framework\Features\BuildTasks\PreBuildTask; +use Hyde\Framework\Concerns\InteractsWithDirectories; + +use function implode; +use function array_map; class TransferMediaAssets extends PreBuildTask { @@ -31,16 +35,25 @@ public function handle(): void $this->skip("No media files to transfer.\n"); } - $this->withProgressBar($files, function (MediaFile $file): void { + $failures = []; + + $this->withProgressBar($files, function (MediaFile $file) use (&$failures): void { $sitePath = $file->getOutputPath(); $this->needsParentDirectory($sitePath); if (! Filesystem::copy($file->getPath(), $sitePath)) { - throw new RuntimeException("Failed to copy media file from [{$file->getPath()}] to [{$sitePath}]"); + $failures[] = "[{$file->getPath()}] to [{$sitePath}]"; } }); $this->newLine(); + + if ($failures !== []) { + throw new RuntimeException( + "Failed to copy media file(s):\n".implode("\n", array_map(fn (string $failure): string => "- $failure", $failures)), + Command::FAILURE + ); + } } public function printFinishMessage(): void diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index 59a3f70dddd..66aa7c736b9 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -4,6 +4,7 @@ namespace Hyde\Framework\Testing\Feature; +use Mockery; use Hyde\Facades\Filesystem; use Hyde\Hyde; use Hyde\Support\BuildWarnings; @@ -13,6 +14,7 @@ use Illuminate\Support\Facades\Process; use Hyde\Framework\HydeServiceProvider; use Hyde\Framework\Actions\StaticPageBuilder; +use Illuminate\Filesystem\Filesystem as BaseFilesystem; use Hyde\Framework\Exceptions\InvalidConfigurationException; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSiteCommand::class)] @@ -126,6 +128,23 @@ public function testBuildCommandIgnoresThumbsDbAndHiddenOrVcsFiles() $this->assertDirectoryDoesNotExist(Hyde::path('_site/media/.git')); } + public function testBuildCommandTransfersEveryFileBeforeReportingAFailedMediaCopy() + { + $this->file('_media/good.txt', 'ok'); + $this->file('_media/bad.txt', 'nope'); + + $mock = Mockery::mock(BaseFilesystem::class)->makePartial(); + $mock->shouldReceive('copy')->withArgs(fn (string $path): bool => str_contains($path, 'bad.txt'))->andReturn(false); + $mock->shouldReceive('copy')->withArgs(fn (string $path): bool => ! str_contains($path, 'bad.txt'))->passthru(); + app()->instance(BaseFilesystem::class, $mock); + + $this->artisan('build')->expectsOutputToContain('[_media/bad.txt] to ['.Hyde::path('_site/media/bad.txt').']'); + + $this->assertFileExists(Hyde::path('_site/media/good.txt')); + + app()->forgetInstance(BaseFilesystem::class); + } + public function testBuildCommandSkipsMediaTransferWhenThereAreNoAssets() { // The file must leave the media directory entirely, since renaming it within a passthrough directory wouldn't empty it. From fa8d16a59556c2f1b614e798773b5a9972590599 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Fri, 28 Aug 2026 18:02:38 +0200 Subject: [PATCH 07/20] Harden and extend media build tests - testBuildCommandSkipsMediaTransferWhenThereAreNoAssets: wrap the rename in try/finally, so a failure between the two renames doesn't leave app.css missing and app.css.bak dirtying the project root for the rest of the suite. - testBuildCommandIgnoresThumbsDbAndHiddenOrVcsFiles: cover desktop.ini too. It previously only had unit coverage against a mocked finder. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0123tyfadjk86BHng4TjBPBH --- .../tests/Feature/StaticSiteServiceTest.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index 66aa7c736b9..6caf058824c 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -114,6 +114,7 @@ public function testBuildCommandDiscoversAndTransfersArbitraryFileTypesWithStruc public function testBuildCommandIgnoresThumbsDbAndHiddenOrVcsFiles() { $this->file('_media/Thumbs.db'); + $this->file('_media/desktop.ini'); $this->file('_media/.gitkeep'); $this->file('_media/.DS_Store'); $this->file('_media/.env'); @@ -122,6 +123,7 @@ public function testBuildCommandIgnoresThumbsDbAndHiddenOrVcsFiles() $this->artisan('build')->assertExitCode(0); $this->assertFileDoesNotExist(Hyde::path('_site/media/Thumbs.db')); + $this->assertFileDoesNotExist(Hyde::path('_site/media/desktop.ini')); $this->assertFileDoesNotExist(Hyde::path('_site/media/.gitkeep')); $this->assertFileDoesNotExist(Hyde::path('_site/media/.DS_Store')); $this->assertFileDoesNotExist(Hyde::path('_site/media/.env')); @@ -150,13 +152,15 @@ public function testBuildCommandSkipsMediaTransferWhenThereAreNoAssets() // The file must leave the media directory entirely, since renaming it within a passthrough directory wouldn't empty it. rename(Hyde::path('_media/app.css'), Hyde::path('app.css.bak')); - $this->artisan('build') - ->expectsOutputToContain('Transferring Media Assets... ') - ->expectsOutputToContain('Skipped') - ->expectsOutputToContain('> No media files to transfer.') - ->assertExitCode(0); - - rename(Hyde::path('app.css.bak'), Hyde::path('_media/app.css')); + try { + $this->artisan('build') + ->expectsOutputToContain('Transferring Media Assets... ') + ->expectsOutputToContain('Skipped') + ->expectsOutputToContain('> No media files to transfer.') + ->assertExitCode(0); + } finally { + rename(Hyde::path('app.css.bak'), Hyde::path('_media/app.css')); + } } public function testBuildCommandDoesNotUseViteDevServerPathEvenWhenViteIsRunning() From dbb49745882f6447a569791617d7513c2d8dee79 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Fri, 28 Aug 2026 18:02:42 +0200 Subject: [PATCH 08/20] Reuse mockFileFinder for the ignored-filename test Give mockFileFinder() an optional files parameter instead of reimplementing the same mock inline. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0123tyfadjk86BHng4TjBPBH --- .../Unit/Foundation/FilesystemHasMediaFilesTest.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php b/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php index c29f8be2bd1..b7d20abf843 100644 --- a/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php +++ b/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php @@ -98,15 +98,13 @@ public function testItSupportsCustomMediaDirectory() public function testItIgnoresThumbsDbAndDesktopIniRegardlessOfCase() { - $mock = Mockery::mock(FileFinder::class); - $mock->shouldReceive('handle')->andReturn(collect([ + $this->mockFileFinder([ '_media/Thumbs.db', '_media/thumbs.db', '_media/Desktop.ini', '_media/desktop.ini', '_media/image.png', - ])); - app()->instance(FileFinder::class, $mock); + ]); $assets = (new Filesystem(Hyde::getInstance()))->assets(); @@ -138,10 +136,10 @@ public function testDiscoverMediaFilesWithMultipleFiles() $this->assertInstanceOf(MediaFile::class, $result->get('document.pdf')); } - protected function mockFileFinder(): MockInterface + protected function mockFileFinder(array $files = []): MockInterface { $mock = Mockery::mock(FileFinder::class); - $mock->shouldReceive('handle')->andReturn(collect()); + $mock->shouldReceive('handle')->andReturn(collect($files)); app()->instance(FileFinder::class, $mock); return $mock; From fedb66785d914028e91dc59cc9381f93ac974c0c Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Fri, 28 Aug 2026 18:02:53 +0200 Subject: [PATCH 09/20] Fix docs for the media directory passthrough change - Remove the media_extensions section from customization.md; it documented a config key this PR removed. - managing-assets.md: state explicitly that every file in _media is copied regardless of type, skipping only Thumbs.db and desktop.ini. - UPGRADE.md: note that a _static file can now collide with a previously-undiscovered _media file (e.g. a .pdf), which throws a FileConflictException during the build. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0123tyfadjk86BHng4TjBPBH --- UPGRADE.md | 2 ++ docs/creating-content/managing-assets.md | 3 ++- docs/digging-deeper/customization.md | 11 ----------- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index eb69cc43fe6..2982b86653e 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -445,6 +445,8 @@ The `hyde.media_extensions` config option no longer exists, and `_media` now dis If you kept non-asset files in `_media` relying on the extension allow-list to exclude them from the build, such as design source files or notes, move them out of the directory, since they'll now be copied to the built site. +This can also surface a new collision with `_static`: if a file such as `_media/report.pdf` was never discovered before because `.pdf` wasn't on the allow-list, `_static/media/report.pdf` could coexist with it. Now that `_media/report.pdf` is discovered too, the build throws a `FileConflictException` for the collision. Rename or remove one of the two files. + ## Migration Checklist Use this checklist to track your upgrade progress: diff --git a/docs/creating-content/managing-assets.md b/docs/creating-content/managing-assets.md index f7663eb3191..f8f10e045ec 100644 --- a/docs/creating-content/managing-assets.md +++ b/docs/creating-content/managing-assets.md @@ -99,7 +99,8 @@ to add your custom styles. It is also where we import HydeFront. If you compile it will output the same file that's already included in Hyde. - The `_media` folder contains **compiled** (and usually minified) files. When Hyde compiles your static site, -all asset files here will get copied as they are into the `_site/media` folder. +every file here gets copied as-is into the `_site/media` folder, regardless of file type. The only files skipped +are `Thumbs.db` and `desktop.ini`. - The `_site/media` folder contains the files that are served to the user. diff --git a/docs/digging-deeper/customization.md b/docs/digging-deeper/customization.md index 62d4a563c19..0f36bd30f82 100644 --- a/docs/digging-deeper/customization.md +++ b/docs/digging-deeper/customization.md @@ -354,17 +354,6 @@ Learn more in the [Documentation Pages](documentation-pages#sidebar) documentati The following configuration options in the `config/hyde.php` file are intended for advanced users and should only be modified if you fully understand their impact. The code examples show the default values. -### `media_extensions` - -This option allows you to specify file extensions considered as media files, which will be copied to the output directory. -To add more extensions, either append them to the existing array or override the entire array. - -```php title="config/hyde.php" -use \Hyde\Support\Filesystem\MediaFile; - -'media_extensions' => array_merge([], MediaFile::EXTENSIONS), -``` - ### `generate_build_manifest` Determines whether a JSON build manifest with metadata about the build should be generated. Set to `true` to enable. From 445eaf430cc419de92b80f076462d8fd117a51ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:33:24 +0000 Subject: [PATCH 10/20] Serve exactly the media files that the build publishes The realtime compiler proxied any file under the media directory, while the build skipped dotfiles, VCS directories, Thumbs.db, and desktop.ini, so `hyde serve` could expose files the built site would not contain. Move the publication rule into MediaFile::isPublishable(), which both build discovery and the AssetFileLocator now use. It mirrors Symfony Finder's default dotfile and VCS exclusions, and stays free of application state since media is proxied before the app boots. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013mibsh9cgLDcoLpZQD3dny --- .../src/Foundation/Concerns/HasMediaFiles.php | 11 +----- .../src/Support/Filesystem/MediaFile.php | 39 +++++++++++++++++++ .../FilesystemHasMediaFilesTest.php | 15 +++++++ .../tests/Unit/Support/MediaFileUnitTest.php | 33 ++++++++++++++++ .../src/Actions/AssetFileLocator.php | 8 +++- .../tests/RealtimeCompilerTest.php | 34 ++++++++++++++++ 6 files changed, 129 insertions(+), 11 deletions(-) diff --git a/packages/framework/src/Foundation/Concerns/HasMediaFiles.php b/packages/framework/src/Foundation/Concerns/HasMediaFiles.php index 1fb58473005..67427b31753 100644 --- a/packages/framework/src/Foundation/Concerns/HasMediaFiles.php +++ b/packages/framework/src/Foundation/Concerns/HasMediaFiles.php @@ -10,9 +10,6 @@ use Illuminate\Support\Collection; use function collect; -use function basename; -use function in_array; -use function strtolower; /** * @internal Single-use trait for the Filesystem class. @@ -21,8 +18,6 @@ */ trait HasMediaFiles { - private const IGNORED_FILENAMES = ['thumbs.db', 'desktop.ini']; - /** @var Collection The Collection keys are the filenames relative to the _media/ directory */ protected Collection $assets; @@ -42,13 +37,11 @@ protected static function discoverMediaFiles(): Collection $file = MediaFile::make($path); return [$file->getIdentifier() => $file]; - }); + })->filter(fn (MediaFile $file, string $identifier): bool => MediaFile::isPublishable($identifier)); } protected static function getMediaFiles(): array { - return Filesystem::findFiles(Hyde::getMediaDirectory(), false, recursive: true) - ->reject(fn (string $path): bool => in_array(strtolower(basename($path)), self::IGNORED_FILENAMES, true)) - ->values()->all(); + return Filesystem::findFiles(Hyde::getMediaDirectory(), recursive: true)->all(); } } diff --git a/packages/framework/src/Support/Filesystem/MediaFile.php b/packages/framework/src/Support/Filesystem/MediaFile.php index cc1232bcef1..a82c72685d2 100644 --- a/packages/framework/src/Support/Filesystem/MediaFile.php +++ b/packages/framework/src/Support/Filesystem/MediaFile.php @@ -16,6 +16,13 @@ use function Hyde\path_join; use function Hyde\trim_slashes; use function array_merge; +use function array_pop; +use function explode; +use function in_array; +use function str_replace; +use function str_starts_with; +use function strtolower; +use function trim; /** * File abstraction for a project media file. @@ -24,6 +31,12 @@ */ class MediaFile extends ProjectFile implements Stringable { + /** @var array Operating system metadata files that are never published, compared case-insensitively */ + protected const IGNORED_FILENAMES = ['thumbs.db', 'desktop.ini']; + + /** @var array Version control directories that are never published, matching Symfony Finder's exclusions */ + protected const VCS_DIRECTORIES = ['.svn', '_svn', 'CVS', '_darcs', '.arch-params', '.monotone', '.bzr', '.git', '.hg']; + protected readonly int $length; protected readonly string $mimeType; protected readonly string $hash; @@ -86,6 +99,32 @@ public static function files(): array return static::all()->keys()->all(); } + /** + * Determine whether a file in the media directory is published to the site. + * + * The media directory is a passthrough, so every file is published except dotfiles, files in dot + * directories or version control directories, and operating system metadata files. This is the + * single rule shared by the build and the realtime compiler, so that serving and building the site + * expose the same files. It must not depend on application state, as the realtime compiler + * proxies media files before the application boots. + * + * @param string $path The file path relative to the media source directory. + */ + public static function isPublishable(string $path): bool + { + $segments = explode('/', trim(str_replace('\\', '/', $path), '/')); + $filename = array_pop($segments); + + foreach ($segments as $directory) { + if (str_starts_with($directory, '.') || in_array($directory, static::VCS_DIRECTORIES, true)) { + return false; + } + } + + return $filename !== '' && ! str_starts_with($filename, '.') + && ! in_array(strtolower($filename), static::IGNORED_FILENAMES, true); + } + /** * Get the absolute path to the media source directory, or a file within it. */ diff --git a/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php b/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php index b7d20abf843..7c21137969f 100644 --- a/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php +++ b/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php @@ -112,6 +112,21 @@ public function testItIgnoresThumbsDbAndDesktopIniRegardlessOfCase() $this->assertTrue($assets->has('image.png')); } + public function testItIgnoresFilesThatAreNotPublishable() + { + $this->mockFileFinder([ + '_media/.gitkeep', + '_media/.hidden/file.txt', + '_media/.git/config', + '_media/CVS/Entries', + '_media/fonts/inter.woff2', + ]); + + $assets = (new Filesystem(Hyde::getInstance()))->assets(); + + $this->assertSame(['fonts/inter.woff2'], $assets->keys()->all()); + } + public function testDiscoverMediaFilesWithEmptyResult() { $this->filesystem->setTestMediaFiles([]); diff --git a/packages/framework/tests/Unit/Support/MediaFileUnitTest.php b/packages/framework/tests/Unit/Support/MediaFileUnitTest.php index 613c614ca31..44dd92e11b6 100644 --- a/packages/framework/tests/Unit/Support/MediaFileUnitTest.php +++ b/packages/framework/tests/Unit/Support/MediaFileUnitTest.php @@ -71,6 +71,12 @@ protected function tearDown(): void $this->verifyMockeryExpectations(); } + #[\PHPUnit\Framework\Attributes\DataProvider('publishablePathsProvider')] + public function testIsPublishable(string $path, bool $expected) + { + $this->assertSame($expected, MediaFile::isPublishable($path)); + } + public function testCanConstruct() { $file = new MediaFile('foo'); @@ -614,6 +620,33 @@ protected function mockCurrentPage(string $page): void Render::shouldReceive('getRouteKey')->andReturn($page); } + public static function publishablePathsProvider(): \Iterator + { + yield ['app.css', true]; + yield ['report.pdf', true]; + yield ['fonts/inter.woff2', true]; + yield ['downloads/archive.zip', true]; + yield ['nested/deeply/data.json', true]; + yield ['README', true]; + yield ['nested\\windows\\path.png', true]; + yield ['CVS', true]; + yield ['Thumbs.db', false]; + yield ['images/THUMBS.DB', false]; + yield ['desktop.ini', false]; + yield ['images/Desktop.ini', false]; + yield ['.gitkeep', false]; + yield ['.DS_Store', false]; + yield ['images/.env', false]; + yield ['.well-known/security.txt', false]; + yield ['nested/.hidden/file.txt', false]; + yield ['.git/config', false]; + yield ['CVS/Entries', false]; + yield ['nested/_darcs/file', false]; + yield ['../outside.txt', false]; + yield ['nested/../../outside.txt', false]; + yield ['', false]; + } + public static function bootableMethodsProvider(): \Iterator { yield ['getLength']; diff --git a/packages/realtime-compiler/src/Actions/AssetFileLocator.php b/packages/realtime-compiler/src/Actions/AssetFileLocator.php index 2a53833f583..6c711e1885a 100644 --- a/packages/realtime-compiler/src/Actions/AssetFileLocator.php +++ b/packages/realtime-compiler/src/Actions/AssetFileLocator.php @@ -4,6 +4,8 @@ namespace Hyde\RealtimeCompiler\Actions; +use Hyde\Support\Filesystem\MediaFile; + /** * Locate a static file to proxy. */ @@ -20,9 +22,11 @@ public static function find(string $path): ?string } if (static::isMediaPath($path)) { - $media = BASE_PATH.'/'.static::mediaDirectory().'/'.substr($path, strlen(static::mediaOutputDirectory()) + 1); + $relativePath = substr($path, strlen(static::mediaOutputDirectory()) + 1); + $media = BASE_PATH.'/'.static::mediaDirectory().'/'.$relativePath; - if (is_file($media)) { + // Only serve media files that the build would publish, so the preview matches the built site. + if (MediaFile::isPublishable($relativePath) && is_file($media)) { return $media; } } diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index 2b4c7309030..ba9549b3486 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -190,6 +190,40 @@ public function testStaticDirectoryTakesPrecedenceForMediaPath(): void } } + public function testServesTheSameMediaFilesThatTheBuildPublishes(): void + { + $files = [ + 'parity/report.pdf' => true, + 'parity/fonts/inter.woff2' => true, + 'parity/nested/data.json' => true, + 'parity/Thumbs.db' => false, + 'parity/DESKTOP.INI' => false, + 'parity/.hidden' => false, + 'parity/.well-known/security.txt' => false, + 'parity/.git/config' => false, + 'parity/CVS/Entries' => false, + ]; + + foreach (array_keys($files) as $file) { + Filesystem::ensureDirectoryExists(dirname("_media/$file")); + Filesystem::put("_media/$file", 'contents'); + } + + try { + $published = (new \Hyde\Foundation\Kernel\Filesystem(Hyde::getInstance()))->assets(); + + foreach ($files as $file => $expected) { + $this->mockCompilerRoute("media/$file"); + $response = (new HttpKernel())->handle(new Request()); + + $this->assertSame($expected, $published->has($file), "Unexpected build discovery result for [$file]"); + $this->assertSame($expected ? 200 : 404, $response->statusCode, "Unexpected serve result for [$file]"); + } + } finally { + Filesystem::deleteDirectory('_media/parity'); + } + } + public function testThrowsRouteNotFoundExceptionForMissingRoute() { $this->mockCompilerRoute('missing'); From 80b4ec194e091cddc272aa782b16a3309c1f2078 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:33:24 +0000 Subject: [PATCH 11/20] Describe _media as public site assets and strengthen the upgrade warning Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013mibsh9cgLDcoLpZQD3dny --- CHANGELOG.md | 3 ++- HYDEPHP_V3_PLANNING.md | 2 +- UPGRADE.md | 8 +++++--- docs/creating-content/managing-assets.md | 14 +++++++++++--- docs/getting-started/core-concepts.md | 4 ++-- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9efc369dbe..a3d5d6267f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,8 @@ This serves two purposes: - Pages with non-HTML output paths are now excluded from automatic navigation by default. Explicit navigation front matter now takes precedence over automatic navigation exclusions. - Fenced code blocks are now rendered through the publishable `components/markdown/code-block.blade.php` view, which changes the generated markup around the code. Syntax highlighting is unaffected, and the `hyde-code-block` and `hyde-code-block-label` classes are stable hooks for your own CSS. - Code block labels are now set with a `title="…"` modifier on the fence, replacing the `// filepath:` comment syntax, which is no longer recognized and must be replaced. -- The `_media` directory is now a passthrough: every file it contains is discovered and copied to the built site, not just files with a recognized extension. `Thumbs.db` and `desktop.ini` are still skipped. +- The `_media` directory is now a passthrough: every file it contains is published to the built site, not just files with a recognized extension. Dotfiles, version control directories, `Thumbs.db`, and `desktop.ini` are still skipped. Review `_media` before upgrading, as anything stored there is now public. +- The realtime compiler now serves exactly the media files that the build publishes, so files like `Thumbs.db` or dotfiles in `_media` are no longer reachable through `php hyde serve`. ### Deprecated - for changes that will be removed in upcoming releases. diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index ccd8cbc0e58..19c8bceb3b4 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -74,7 +74,7 @@ Having this document in code lets us know the devlopment state at any given poin The undocumented `hyde.empty_output_directory` option is removed too. It was never present in the shipped `config/hyde.php` and never documented, so no upgrade step is needed for it. -- The `_media` directory is now a passthrough for every file it contains, rather than only files matching the `hyde.media_extensions` allow-list. That option is removed, along with `MediaFile::EXTENSIONS`, with no deprecated compatibility constant. `Thumbs.db` and `desktop.ini` are still skipped regardless of case, since Symfony Finder's dotfile and VCS exclusions were already relied on and remain unchanged. This lets media types Hyde never had an opinion about — PDFs, fonts, archives — pass through without a config change, at the cost of also copying files a project may have parked in `_media` for other reasons. +- The `_media` directory is now a passthrough for every file it contains, rather than only files matching the `hyde.media_extensions` allow-list. That option is removed, along with `MediaFile::EXTENSIONS`, with no deprecated compatibility constant. `Thumbs.db` and `desktop.ini` are still skipped regardless of case, since Symfony Finder's dotfile and VCS exclusions were already relied on and remain unchanged. This lets media types Hyde never had an opinion about — PDFs, fonts, archives — pass through without a config change, at the cost of also copying files a project may have parked in `_media` for other reasons. The publication rule lives in `MediaFile::isPublishable()`, which both the build and `hyde serve` use, so the preview server no longer serves `_media` files the build skips, such as `Thumbs.db` or dotfiles. ### Upgrade guide diff --git a/UPGRADE.md b/UPGRADE.md index 2982b86653e..6f60d32cb11 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -441,9 +441,11 @@ The `hyde.safe_output_directories` option no longer exists, and the build no lon ## Step 13: Review the Contents of Your Media Directory -The `hyde.media_extensions` config option no longer exists, and `_media` now discovers every file it contains, not just files with a recognized extension. Delete the entry from your `config/hyde.php`. +`_media` is now a passthrough directory: files are no longer filtered by extension, and every file it contains is published to `_site/media`, regardless of its type. The only exceptions are dotfiles, files in dot directories or version control directories, and the `Thumbs.db` and `desktop.ini` files created by operating systems. `php hyde serve` follows the exact same rules, so it no longer serves files that the build doesn't publish. -If you kept non-asset files in `_media` relying on the extension allow-list to exclude them from the build, such as design source files or notes, move them out of the directory, since they'll now be copied to the built site. +>warning This changes what you deploy. **Review the contents of `_media` before upgrading.** Any normal file stored there, such as design source files, notes, exports, or source maps, will now be publicly accessible on your built site. Move anything that should not be public out of `_media`. + +The `hyde.media_extensions` config option no longer exists, and there is no replacement, since `_media` itself decides what gets published. Delete the entry from your `config/hyde.php`. This can also surface a new collision with `_static`: if a file such as `_media/report.pdf` was never discovered before because `.pdf` wasn't on the allow-list, `_static/media/report.pdf` could coexist with it. Now that `_media/report.pdf` is discovered too, the build throws a `FileConflictException` for the collision. Rename or remove one of the two files. @@ -464,7 +466,7 @@ Use this checklist to track your upgrade progress: - [ ] Compared pages against your old site if you have custom CSS for code blocks or their labels - [ ] Checked `_posts` for drafts and blog posts dated in the future, and set up recurring builds if scheduling posts - [ ] Moved manually maintained files out of the output directory and into `_static` -- [ ] Removed `hyde.media_extensions` from `config/hyde.php` and moved any non-asset files out of `_media` +- [ ] Reviewed `_media`, moved out anything that shouldn't be public, and removed `hyde.media_extensions` from `config/hyde.php` ## Troubleshooting diff --git a/docs/creating-content/managing-assets.md b/docs/creating-content/managing-assets.md index f8f10e045ec..29feaa8f4a0 100644 --- a/docs/creating-content/managing-assets.md +++ b/docs/creating-content/managing-assets.md @@ -98,12 +98,20 @@ Here you will find the `app.css` file that bootstraps the TailwindCSS styles. Th to add your custom styles. It is also where we import HydeFront. If you compile this file in the base install, it will output the same file that's already included in Hyde. -- The `_media` folder contains **compiled** (and usually minified) files. When Hyde compiles your static site, -every file here gets copied as-is into the `_site/media` folder, regardless of file type. The only files skipped -are `Thumbs.db` and `desktop.ini`. +- The `_media` folder contains your **public** site assets. When Hyde compiles your static site, every file here +gets copied as-is into the `_site/media` folder, preserving its path and regardless of its file type. This includes +images, stylesheets, scripts, fonts, documents, downloads, and any other static files. Vite also writes the compiled +(and usually minified) styles and scripts from `resources/assets` here. - The `_site/media` folder contains the files that are served to the user. +>warning Everything in `_media` should be considered public. Don't keep design source files, notes, or anything else you don't want to deploy in this folder. + +The only files in `_media` that are not published are dotfiles (like `.gitkeep`), files in dot directories or version +control directories (like `.git`), and the `Thumbs.db` and `desktop.ini` files created by operating systems. The same +rules apply when previewing your site with `php hyde serve`, so the preview exposes exactly the files the build publishes. +If you need a publicly accessible dotfile, such as `.well-known/security.txt`, place it in `_static` instead. + ### What is the difference between `_media` and `_site/media`? It may seem weird to have two folders for storing the compiled assets, but it is quite useful. diff --git a/docs/getting-started/core-concepts.md b/docs/getting-started/core-concepts.md index a85cbe58802..67b204cbfdc 100644 --- a/docs/getting-started/core-concepts.md +++ b/docs/getting-started/core-concepts.md @@ -38,7 +38,7 @@ To take full advantage of the framework, it may first be good to familiarize our | `_docs` | For documentation pages | | `_posts` | For blog posts | | `_pages` | For static Markdown and Blade pages | -| `_media` | Store static assets to be copied to the build directory | +| `_media` | Public site assets copied as-is to the build directory | | `_site` | The build directory where your compiled site will be stored | | `config` | Configuration files for Hyde and integrations | | `resources/assets` | Location for CSS/JS Vite source files (optional) | @@ -72,7 +72,7 @@ All source and output directories are configurable, but the defaults are as foll | Static Pages | `_pages/` | `_site/` | `.md`, `.blade.php` | | Blog Posts | `_posts/` | `_site/posts/` | `.md` | | Documentation | `_docs/` | `_site/docs/` | `.md` | -| Media Assets | `_media/` | `_site/media/` | Common asset types | +| Media Assets | `_media/` | `_site/media/` | Any file type | ## Paths, Identifiers, and Route Keys From 851f10c7ab71a302e4d876b21952879d5bca30ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:42:30 +0000 Subject: [PATCH 12/20] Mark the media publication rule as internal and drop narrating comments Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013mibsh9cgLDcoLpZQD3dny --- packages/framework/src/Support/Filesystem/MediaFile.php | 9 ++++----- .../realtime-compiler/src/Actions/AssetFileLocator.php | 1 - 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/framework/src/Support/Filesystem/MediaFile.php b/packages/framework/src/Support/Filesystem/MediaFile.php index a82c72685d2..35995f3b22d 100644 --- a/packages/framework/src/Support/Filesystem/MediaFile.php +++ b/packages/framework/src/Support/Filesystem/MediaFile.php @@ -102,11 +102,10 @@ public static function files(): array /** * Determine whether a file in the media directory is published to the site. * - * The media directory is a passthrough, so every file is published except dotfiles, files in dot - * directories or version control directories, and operating system metadata files. This is the - * single rule shared by the build and the realtime compiler, so that serving and building the site - * expose the same files. It must not depend on application state, as the realtime compiler - * proxies media files before the application boots. + * Shared by the build and the realtime compiler, which calls it before the application boots, + * so it must not depend on application state. + * + * @internal * * @param string $path The file path relative to the media source directory. */ diff --git a/packages/realtime-compiler/src/Actions/AssetFileLocator.php b/packages/realtime-compiler/src/Actions/AssetFileLocator.php index 6c711e1885a..edfc47c967c 100644 --- a/packages/realtime-compiler/src/Actions/AssetFileLocator.php +++ b/packages/realtime-compiler/src/Actions/AssetFileLocator.php @@ -25,7 +25,6 @@ public static function find(string $path): ?string $relativePath = substr($path, strlen(static::mediaOutputDirectory()) + 1); $media = BASE_PATH.'/'.static::mediaDirectory().'/'.$relativePath; - // Only serve media files that the build would publish, so the preview matches the built site. if (MediaFile::isPublishable($relativePath) && is_file($media)) { return $media; } From eb416744c1c64836b161879b2e898e6c53a645ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:42:38 +0000 Subject: [PATCH 13/20] Keep the _media documentation proportional to the user-facing change Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013mibsh9cgLDcoLpZQD3dny --- CHANGELOG.md | 3 +-- UPGRADE.md | 2 +- docs/creating-content/managing-assets.md | 14 +++----------- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d5d6267f4..1192ce5fbe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,7 @@ This serves two purposes: - Pages with non-HTML output paths are now excluded from automatic navigation by default. Explicit navigation front matter now takes precedence over automatic navigation exclusions. - Fenced code blocks are now rendered through the publishable `components/markdown/code-block.blade.php` view, which changes the generated markup around the code. Syntax highlighting is unaffected, and the `hyde-code-block` and `hyde-code-block-label` classes are stable hooks for your own CSS. - Code block labels are now set with a `title="…"` modifier on the fence, replacing the `// filepath:` comment syntax, which is no longer recognized and must be replaced. -- The `_media` directory is now a passthrough: every file it contains is published to the built site, not just files with a recognized extension. Dotfiles, version control directories, `Thumbs.db`, and `desktop.ini` are still skipped. Review `_media` before upgrading, as anything stored there is now public. -- The realtime compiler now serves exactly the media files that the build publishes, so files like `Thumbs.db` or dotfiles in `_media` are no longer reachable through `php hyde serve`. +- The `_media` directory is now a passthrough: every file it contains is published to the built site, not just files with a recognized extension. Review `_media` before upgrading, as anything stored there is now public. ### Deprecated - for changes that will be removed in upcoming releases. diff --git a/UPGRADE.md b/UPGRADE.md index 6f60d32cb11..1b31c5383f1 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -441,7 +441,7 @@ The `hyde.safe_output_directories` option no longer exists, and the build no lon ## Step 13: Review the Contents of Your Media Directory -`_media` is now a passthrough directory: files are no longer filtered by extension, and every file it contains is published to `_site/media`, regardless of its type. The only exceptions are dotfiles, files in dot directories or version control directories, and the `Thumbs.db` and `desktop.ini` files created by operating systems. `php hyde serve` follows the exact same rules, so it no longer serves files that the build doesn't publish. +`_media` is now a passthrough directory: files are no longer filtered by extension, and every file it contains is published to `_site/media`, regardless of its type. >warning This changes what you deploy. **Review the contents of `_media` before upgrading.** Any normal file stored there, such as design source files, notes, exports, or source maps, will now be publicly accessible on your built site. Move anything that should not be public out of `_media`. diff --git a/docs/creating-content/managing-assets.md b/docs/creating-content/managing-assets.md index 29feaa8f4a0..ed05a09f3b0 100644 --- a/docs/creating-content/managing-assets.md +++ b/docs/creating-content/managing-assets.md @@ -98,20 +98,12 @@ Here you will find the `app.css` file that bootstraps the TailwindCSS styles. Th to add your custom styles. It is also where we import HydeFront. If you compile this file in the base install, it will output the same file that's already included in Hyde. -- The `_media` folder contains your **public** site assets. When Hyde compiles your static site, every file here -gets copied as-is into the `_site/media` folder, preserving its path and regardless of its file type. This includes -images, stylesheets, scripts, fonts, documents, downloads, and any other static files. Vite also writes the compiled -(and usually minified) styles and scripts from `resources/assets` here. +- The `_media` folder contains your public site assets, such as images, stylesheets, scripts, fonts, and downloads. +When Hyde compiles your static site, every file here gets copied as-is into the `_site/media` folder. This is also +where Vite writes the compiled (and usually minified) styles and scripts from `resources/assets`. - The `_site/media` folder contains the files that are served to the user. ->warning Everything in `_media` should be considered public. Don't keep design source files, notes, or anything else you don't want to deploy in this folder. - -The only files in `_media` that are not published are dotfiles (like `.gitkeep`), files in dot directories or version -control directories (like `.git`), and the `Thumbs.db` and `desktop.ini` files created by operating systems. The same -rules apply when previewing your site with `php hyde serve`, so the preview exposes exactly the files the build publishes. -If you need a publicly accessible dotfile, such as `.well-known/security.txt`, place it in `_static` instead. - ### What is the difference between `_media` and `_site/media`? It may seem weird to have two folders for storing the compiled assets, but it is quite useful. From 0e3429a61d9ed7e89a893bc6f1283ff69853ad36 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 15:00:46 +0000 Subject: [PATCH 14/20] Remove media_extensions from the config before updating dependencies A published config/hyde.php entry references the removed MediaFile::EXTENSIONS constant, and composer update runs package:discover, which loads the config. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013mibsh9cgLDcoLpZQD3dny --- HYDEPHP_V3_PLANNING.md | 2 +- UPGRADE.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 19c8bceb3b4..01448f44fc2 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -94,7 +94,7 @@ Please fill in UPGRADE.md as you make changes. - Compare a few pages against your old site if you have custom CSS for code blocks or their labels, since the generated markup changed. The `hyde-code-block` and `hyde-code-block-label` classes are stable hooks to target instead of the markup structure. - Port any customizations from a published `filepath-label.blade.php` to `markdown/code-block.blade.php`. The old file is ignored after upgrading, so the site renders with the shipped label until they are moved. - Move manually maintained files out of the output directory and into `_static`, since the whole output directory is now emptied before every build. Remove `safe_output_directories` from a published `config/hyde.php`. -- Remove `media_extensions` from a published `config/hyde.php`, and move any non-asset files out of `_media` that you were relying on the extension allow-list to keep out of the build. +- Remove `media_extensions` from a published `config/hyde.php` before updating Composer dependencies, since it references the removed `MediaFile::EXTENSIONS` constant, and move any non-asset files out of `_media` that you were relying on the extension allow-list to keep out of the build. ## `InMemoryPage` content-source motivation diff --git a/UPGRADE.md b/UPGRADE.md index 1b31c5383f1..70233155c89 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -47,6 +47,8 @@ git commit -m "Pre-upgrade backup before HydePHP v3.0" // +>warning If your published `config/hyde.php` has a `media_extensions` entry, delete it **before** updating your Composer dependencies. The entry references the removed `MediaFile::EXTENSIONS` constant, so the application fails to load, including during `composer update`, until it is gone. Files in `_media` are no longer filtered by extension, so the option has no replacement. See [Step 13](#step-13-review-the-contents-of-your-media-directory). + ### Update Node Dependencies HydePHP v3 upgrades the bundled `vite` dependency from v7 to v8. Update your `package.json` `devDependencies` to require the new major version: @@ -445,14 +447,13 @@ The `hyde.safe_output_directories` option no longer exists, and the build no lon >warning This changes what you deploy. **Review the contents of `_media` before upgrading.** Any normal file stored there, such as design source files, notes, exports, or source maps, will now be publicly accessible on your built site. Move anything that should not be public out of `_media`. -The `hyde.media_extensions` config option no longer exists, and there is no replacement, since `_media` itself decides what gets published. Delete the entry from your `config/hyde.php`. - This can also surface a new collision with `_static`: if a file such as `_media/report.pdf` was never discovered before because `.pdf` wasn't on the allow-list, `_static/media/report.pdf` could coexist with it. Now that `_media/report.pdf` is discovered too, the build throws a `FileConflictException` for the collision. Rename or remove one of the two files. ## Migration Checklist Use this checklist to track your upgrade progress: +- [ ] Removed any `media_extensions` entry from `config/hyde.php` before updating Composer dependencies - [ ] Reviewed `markdown.allow_html` and `markdown.enable_blade` and explicitly selected the appropriate trust policy - [ ] Replaced any `php hyde rebuild ` usage with `StaticPageBuilder::handle()` or a full `php hyde build` - [ ] Moved calls to `Redirect::create()` or `Redirect::store()` into the `hyde.redirects` configuration array @@ -466,7 +467,7 @@ Use this checklist to track your upgrade progress: - [ ] Compared pages against your old site if you have custom CSS for code blocks or their labels - [ ] Checked `_posts` for drafts and blog posts dated in the future, and set up recurring builds if scheduling posts - [ ] Moved manually maintained files out of the output directory and into `_static` -- [ ] Reviewed `_media`, moved out anything that shouldn't be public, and removed `hyde.media_extensions` from `config/hyde.php` +- [ ] Reviewed `_media` and moved out anything that shouldn't be public ## Troubleshooting From 548e06e8b9be7718f4f7889eb76b0294e23bbf02 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 15:00:46 +0000 Subject: [PATCH 15/20] Drop the _static and _media collision note from the upgrade guide The build already reports a FileConflictException for the rare case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013mibsh9cgLDcoLpZQD3dny --- UPGRADE.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 70233155c89..ecd50428d9a 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -447,8 +447,6 @@ The `hyde.safe_output_directories` option no longer exists, and the build no lon >warning This changes what you deploy. **Review the contents of `_media` before upgrading.** Any normal file stored there, such as design source files, notes, exports, or source maps, will now be publicly accessible on your built site. Move anything that should not be public out of `_media`. -This can also surface a new collision with `_static`: if a file such as `_media/report.pdf` was never discovered before because `.pdf` wasn't on the allow-list, `_static/media/report.pdf` could coexist with it. Now that `_media/report.pdf` is discovered too, the build throws a `FileConflictException` for the collision. Rename or remove one of the two files. - ## Migration Checklist Use this checklist to track your upgrade progress: From 3d6c567475ffc8e3128377a53d80457354e01ceb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 15:01:31 +0000 Subject: [PATCH 16/20] List the removed MediaFile::EXTENSIONS constant in the changelog Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013mibsh9cgLDcoLpZQD3dny --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1192ce5fbe6..a37b6d73293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ This serves two purposes: - Removed the `rebuild` command. It had no remaining internal consumers now that the realtime compiler renders pages in-memory, and single-page builds can silently leave aggregate outputs (sitemap, RSS, search index, navigation) stale. Use `Hyde\Framework\Actions\StaticPageBuilder::handle()` instead if you need to build a single page programmatically. - Removed the `InMemoryPage` instance macro API. Use a contents closure for dynamic output, or extend `InMemoryPage` to add custom methods and behavior. - Removed the `components/filepath-label.blade.php` view, as the label markup now lives in the code block view. If this was published, port any customizations into `components/markdown/code-block.blade.php`. -- Removed the `hyde.media_extensions` config option, now that the media directory discovers files of every type. +- Removed the `hyde.media_extensions` config option and the `MediaFile::EXTENSIONS` constant, now that the media directory discovers files of every type. ### Fixed - Fixed Markdown-to-plain-text conversion consuming unrelated content after ATX headings and stripping literal trailing hashes in https://github.com/hydephp/develop/pull/2590 From 384fe41f3babbf94ff15860bee8a4b0b7462774f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:51:34 +0000 Subject: [PATCH 17/20] Report media copies that throw instead of aborting the transfer Filesystem::copy() is a direct call to PHP's copy(), which raises a warning on failure rather than returning false. The application turns warnings into ErrorExceptions, so the first failing file escaped the progress callback and the remaining files were never attempted. Collect those exceptions alongside false returns, including the reason in the failure list, and cover it with a copy that fails the way PHP does. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013mibsh9cgLDcoLpZQD3dny --- .../PreBuildTasks/TransferMediaAssets.php | 16 +++++++++++--- .../tests/Feature/StaticSiteServiceTest.php | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php b/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php index 6569b019bdb..b1c85fdfc3c 100644 --- a/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php +++ b/packages/framework/src/Framework/Actions/PreBuildTasks/TransferMediaAssets.php @@ -4,6 +4,7 @@ namespace Hyde\Framework\Actions\PreBuildTasks; +use ErrorException; use RuntimeException; use Hyde\Facades\Config; use Hyde\Facades\Filesystem; @@ -39,10 +40,19 @@ public function handle(): void $this->withProgressBar($files, function (MediaFile $file) use (&$failures): void { $sitePath = $file->getOutputPath(); - $this->needsParentDirectory($sitePath); + $transfer = "[{$file->getPath()}] to [{$sitePath}]"; - if (! Filesystem::copy($file->getPath(), $sitePath)) { - $failures[] = "[{$file->getPath()}] to [{$sitePath}]"; + try { + $this->needsParentDirectory($sitePath); + $copied = Filesystem::copy($file->getPath(), $sitePath); + } catch (ErrorException $exception) { + $failures[] = "$transfer: {$exception->getMessage()}"; + + return; + } + + if (! $copied) { + $failures[] = $transfer; } }); diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index 6caf058824c..0f8cca0cbae 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -147,6 +147,28 @@ public function testBuildCommandTransfersEveryFileBeforeReportingAFailedMediaCop app()->forgetInstance(BaseFilesystem::class); } + public function testBuildCommandTransfersEveryFileBeforeReportingACopyThatThrows() + { + $this->file('_media/good.txt', 'ok'); + $this->file('_media/bad.txt', 'nope'); + + $mock = Mockery::mock(BaseFilesystem::class)->makePartial(); + $mock->shouldReceive('copy')->withArgs(fn (string $path): bool => str_contains($path, 'bad.txt')) + ->andReturnUsing(fn (string $path, string $target): bool => copy(Hyde::path('missing/source.txt'), $target)); + $mock->shouldReceive('copy')->withArgs(fn (string $path): bool => ! str_contains($path, 'bad.txt'))->passthru(); + app()->instance(BaseFilesystem::class, $mock); + + try { + $this->artisan('build')->expectsOutputToContain( + '[_media/bad.txt] to ['.Hyde::path('_site/media/bad.txt').']: copy('.Hyde::path('missing/source.txt').'): Failed to open stream' + ); + + $this->assertFileExists(Hyde::path('_site/media/good.txt')); + } finally { + app()->forgetInstance(BaseFilesystem::class); + } + } + public function testBuildCommandSkipsMediaTransferWhenThereAreNoAssets() { // The file must leave the media directory entirely, since renaming it within a passthrough directory wouldn't empty it. From c0e2816fc8ae3c22084dea017990855395a9b271 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:52:30 +0000 Subject: [PATCH 18/20] Refuse to resolve media files that are not published as assets MediaFile::get() fell back to MediaFile::make() for files missing from the discovered collection, so Hyde::asset('.env') linked to a file that neither the build nor the preview server publishes. Check the publication rule after resolving the file and throw a FileNotFoundException that says why, so the build, hyde serve, and Hyde::asset() agree. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013mibsh9cgLDcoLpZQD3dny --- HYDEPHP_V3_PLANNING.md | 2 +- packages/framework/src/Facades/Asset.php | 2 +- .../Concerns/ForwardsHyperlinks.php | 2 +- .../src/Foundation/Kernel/Hyperlinks.php | 2 +- .../src/Support/Filesystem/MediaFile.php | 15 ++++++- packages/framework/src/helpers.php | 2 +- .../tests/Feature/Support/MediaFileTest.php | 40 +++++++++++++++++++ 7 files changed, 58 insertions(+), 7 deletions(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 01448f44fc2..de67eab4e25 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -74,7 +74,7 @@ Having this document in code lets us know the devlopment state at any given poin The undocumented `hyde.empty_output_directory` option is removed too. It was never present in the shipped `config/hyde.php` and never documented, so no upgrade step is needed for it. -- The `_media` directory is now a passthrough for every file it contains, rather than only files matching the `hyde.media_extensions` allow-list. That option is removed, along with `MediaFile::EXTENSIONS`, with no deprecated compatibility constant. `Thumbs.db` and `desktop.ini` are still skipped regardless of case, since Symfony Finder's dotfile and VCS exclusions were already relied on and remain unchanged. This lets media types Hyde never had an opinion about — PDFs, fonts, archives — pass through without a config change, at the cost of also copying files a project may have parked in `_media` for other reasons. The publication rule lives in `MediaFile::isPublishable()`, which both the build and `hyde serve` use, so the preview server no longer serves `_media` files the build skips, such as `Thumbs.db` or dotfiles. +- The `_media` directory is now a passthrough for every file it contains, rather than only files matching the `hyde.media_extensions` allow-list. That option is removed, along with `MediaFile::EXTENSIONS`, with no deprecated compatibility constant. `Thumbs.db` and `desktop.ini` are still skipped regardless of case, since Symfony Finder's dotfile and VCS exclusions were already relied on and remain unchanged. This lets media types Hyde never had an opinion about — PDFs, fonts, archives — pass through without a config change, at the cost of also copying files a project may have parked in `_media` for other reasons. The publication rule lives in `MediaFile::isPublishable()`, which the build, `hyde serve`, and `Hyde::asset()` all use, so the preview server no longer serves `_media` files the build skips, such as `Thumbs.db` or dotfiles, and `Hyde::asset()` refuses to link to them. ### Upgrade guide diff --git a/packages/framework/src/Facades/Asset.php b/packages/framework/src/Facades/Asset.php index 3bc01acfa1c..c6695a6766e 100644 --- a/packages/framework/src/Facades/Asset.php +++ b/packages/framework/src/Facades/Asset.php @@ -16,7 +16,7 @@ class Asset /** * Get a MediaFile instance for the given filename in the media source directory. * - * @throws \Hyde\Framework\Exceptions\FileNotFoundException If the file does not exist in the `_media` source directory. + * @throws \Hyde\Framework\Exceptions\FileNotFoundException If the file does not exist in the `_media` source directory, or is not published to the site. */ public static function get(string $file): MediaFile { diff --git a/packages/framework/src/Foundation/Concerns/ForwardsHyperlinks.php b/packages/framework/src/Foundation/Concerns/ForwardsHyperlinks.php index 9c7cd2aded7..960f8e6d1e6 100644 --- a/packages/framework/src/Foundation/Concerns/ForwardsHyperlinks.php +++ b/packages/framework/src/Foundation/Concerns/ForwardsHyperlinks.php @@ -24,7 +24,7 @@ public function relativeLink(string $destination): string return $this->hyperlinks->relativeLink($destination); } - /** @throws \Hyde\Framework\Exceptions\FileNotFoundException If the file does not exist in the `_media` source directory. */ + /** @throws \Hyde\Framework\Exceptions\FileNotFoundException If the file does not exist in the `_media` source directory, or is not published to the site. */ public function asset(string $name): MediaFile { return $this->hyperlinks->asset($name); diff --git a/packages/framework/src/Foundation/Kernel/Hyperlinks.php b/packages/framework/src/Foundation/Kernel/Hyperlinks.php index c23498ebae3..f66b882c97c 100644 --- a/packages/framework/src/Foundation/Kernel/Hyperlinks.php +++ b/packages/framework/src/Foundation/Kernel/Hyperlinks.php @@ -92,7 +92,7 @@ public function relativeLink(string $destination): string * If a base URL is configured, the image will be returned with a qualified absolute URL. * Otherwise, a relative path will be returned based on the rendered page's location. * - * @throws \Hyde\Framework\Exceptions\FileNotFoundException If the file does not exist in the `_media` source directory. + * @throws \Hyde\Framework\Exceptions\FileNotFoundException If the file does not exist in the `_media` source directory, or is not published to the site. */ public function asset(string $name): MediaFile { diff --git a/packages/framework/src/Support/Filesystem/MediaFile.php b/packages/framework/src/Support/Filesystem/MediaFile.php index 35995f3b22d..041ae3fac1a 100644 --- a/packages/framework/src/Support/Filesystem/MediaFile.php +++ b/packages/framework/src/Support/Filesystem/MediaFile.php @@ -19,6 +19,7 @@ use function array_pop; use function explode; use function in_array; +use function sprintf; use function str_replace; use function str_starts_with; use function strtolower; @@ -72,11 +73,21 @@ public static function make(string $path): static /** * Get or create a media file instance from the HydeKernel for the given file. * - * @throws \Hyde\Framework\Exceptions\FileNotFoundException If the file does not exist in the `_media` source directory. + * @throws \Hyde\Framework\Exceptions\FileNotFoundException If the file does not exist in the `_media` source directory, or is not published to the site. */ public static function get(string $path): MediaFile { - return Hyde::assets()->get($path) ?? static::make($path); + $file = Hyde::assets()->get($path) ?? static::make($path); + + if (! static::isPublishable($file->getIdentifier())) { + throw new FileNotFoundException($file->getPath(), sprintf( + 'File [%s] is not published to the built site, so it cannot be used as an asset. ' + .'Dotfiles, version control directories, Thumbs.db, and desktop.ini are excluded from the media directory.', + $file->getPath() + )); + } + + return $file; } /** diff --git a/packages/framework/src/helpers.php b/packages/framework/src/helpers.php index c7089ef6f74..7e75d74a6b2 100644 --- a/packages/framework/src/helpers.php +++ b/packages/framework/src/helpers.php @@ -27,7 +27,7 @@ function hyde(): HydeKernel * If a base URL is configured, the image will be returned with a qualified absolute URL. * Otherwise, a relative path will be returned based on the rendered page's location. * - * @throws \Hyde\Framework\Exceptions\FileNotFoundException If the file does not exist in the `_media` source directory. + * @throws \Hyde\Framework\Exceptions\FileNotFoundException If the file does not exist in the `_media` source directory, or is not published to the site. */ function asset(string $name): MediaFile { diff --git a/packages/framework/tests/Feature/Support/MediaFileTest.php b/packages/framework/tests/Feature/Support/MediaFileTest.php index 8d284490cc6..a3e4e080c10 100644 --- a/packages/framework/tests/Feature/Support/MediaFileTest.php +++ b/packages/framework/tests/Feature/Support/MediaFileTest.php @@ -84,6 +84,46 @@ public function testMediaFileExceptionHandling() MediaFile::make('non_existent_file.txt')->getLength(); } + #[\PHPUnit\Framework\Attributes\DataProvider('unpublishedMediaFilesProvider')] + public function testExistingFilesThatAreNotPublishedCannotBeResolvedAsAssets(string $file) + { + $this->file("_media/$file"); + + $this->expectException(FileNotFoundException::class); + $this->expectExceptionMessage("File [_media/$file] is not published to the built site"); + + Hyde::asset($file); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unpublishedMediaFilesProvider')] + public function testGettingAFileThatIsNotPublishedThrows(string $file) + { + $this->file("_media/$file"); + + $this->expectException(FileNotFoundException::class); + + MediaFile::get($file); + } + + public function testFilesThatAreStillPublishedCanBeResolvedAsAssets() + { + $this->file('_media/report.pdf'); + + $this->assertSame('_media/report.pdf', Hyde::asset('report.pdf')->getPath()); + } + + public static function unpublishedMediaFilesProvider(): array + { + return [ + 'dotfile' => ['.env'], + 'nested dotfile' => ['images/.DS_Store'], + 'dot directory' => ['.well-known/security.txt'], + 'version control directory' => ['.git/config'], + 'Thumbs.db' => ['Thumbs.db'], + 'desktop.ini' => ['images/desktop.ini'], + ]; + } + public function testMediaDirectoryCustomization() { Hyde::setMediaDirectory('custom_media'); From af3271b3c32596c5246226443fc8f0c0afb3461b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:52:39 +0000 Subject: [PATCH 19/20] Avoid overstating that every file in _media is published Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013mibsh9cgLDcoLpZQD3dny --- CHANGELOG.md | 2 +- UPGRADE.md | 2 +- docs/creating-content/managing-assets.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a37b6d73293..84a8cfdebaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ This serves two purposes: - Pages with non-HTML output paths are now excluded from automatic navigation by default. Explicit navigation front matter now takes precedence over automatic navigation exclusions. - Fenced code blocks are now rendered through the publishable `components/markdown/code-block.blade.php` view, which changes the generated markup around the code. Syntax highlighting is unaffected, and the `hyde-code-block` and `hyde-code-block-label` classes are stable hooks for your own CSS. - Code block labels are now set with a `title="…"` modifier on the fence, replacing the `// filepath:` comment syntax, which is no longer recognized and must be replaced. -- The `_media` directory is now a passthrough: every file it contains is published to the built site, not just files with a recognized extension. Review `_media` before upgrading, as anything stored there is now public. +- The `_media` directory is now a passthrough: files are published to the built site regardless of file type, not just those with a recognized extension. Review `_media` before upgrading, as anything stored there is now public. ### Deprecated - for changes that will be removed in upcoming releases. diff --git a/UPGRADE.md b/UPGRADE.md index ecd50428d9a..8ca84170d74 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -443,7 +443,7 @@ The `hyde.safe_output_directories` option no longer exists, and the build no lon ## Step 13: Review the Contents of Your Media Directory -`_media` is now a passthrough directory: files are no longer filtered by extension, and every file it contains is published to `_site/media`, regardless of its type. +`_media` is now a passthrough directory: files are no longer filtered by extension, and are published to `_site/media` regardless of their type. >warning This changes what you deploy. **Review the contents of `_media` before upgrading.** Any normal file stored there, such as design source files, notes, exports, or source maps, will now be publicly accessible on your built site. Move anything that should not be public out of `_media`. diff --git a/docs/creating-content/managing-assets.md b/docs/creating-content/managing-assets.md index ed05a09f3b0..229455e2636 100644 --- a/docs/creating-content/managing-assets.md +++ b/docs/creating-content/managing-assets.md @@ -99,7 +99,7 @@ to add your custom styles. It is also where we import HydeFront. If you compile it will output the same file that's already included in Hyde. - The `_media` folder contains your public site assets, such as images, stylesheets, scripts, fonts, and downloads. -When Hyde compiles your static site, every file here gets copied as-is into the `_site/media` folder. This is also +When Hyde compiles your static site, files here are copied as-is into the `_site/media` folder, regardless of file type. This is also where Vite writes the compiled (and usually minified) styles and scripts from `resources/assets`. - The `_site/media` folder contains the files that are served to the user. From 5368e1f59b11286a795a77e66a210d6d09d3aa0d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:22:57 +0000 Subject: [PATCH 20/20] Regenerate the HydeKernel docs for the asset() PHPDoc change Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013mibsh9cgLDcoLpZQD3dny --- .../partials/hyde-pages-api/hyde-kernel-hyperlink-methods.md | 4 ++-- docs/architecture-concepts/the-hydekernel.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_data/partials/hyde-pages-api/hyde-kernel-hyperlink-methods.md b/docs/_data/partials/hyde-pages-api/hyde-kernel-hyperlink-methods.md index d3d7e31ab61..f7b415b277f 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-kernel-hyperlink-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-kernel-hyperlink-methods.md @@ -1,7 +1,7 @@