From e52fd9ba1d0dcef47650a7a8c13d56ebad35dca0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 10 Aug 2026 20:50:02 +0200 Subject: [PATCH 1/7] Empty the entire output directory before every build The output directory is now wholly owned by Hyde and recreated from source on every build, replacing the selective HTML/JSON cleaning that existed because there was no source-controlled home for arbitrary root files. Removes the `empty_output_directory` and `safe_output_directories` options along with the interactive confirmation, and guards against a misconfigured output directory resolving outside the project. Co-Authored-By: Claude Opus 5 --- config/hyde.php | 5 - packages/framework/config/hyde.php | 5 - .../src/Console/Commands/BuildSiteCommand.php | 3 + .../Internal/OutputDirectoryValidator.php | 106 ++++++++++++++++++ .../PreBuildTasks/CleanSiteDirectory.php | 48 +------- .../Framework/Services/BuildTaskService.php | 7 +- 6 files changed, 115 insertions(+), 59 deletions(-) create mode 100644 packages/framework/src/Framework/Actions/Internal/OutputDirectoryValidator.php diff --git a/config/hyde.php b/config/hyde.php index 0214fd7d1d8..b667610c7f5 100644 --- a/config/hyde.php +++ b/config/hyde.php @@ -487,11 +487,6 @@ // 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), - // The list of directories that are considered to be safe to empty upon site build. - // If the site output directory is set to a directory that is not in this list, - // the build command will prompt for confirmation before emptying it. - 'safe_output_directories' => ['_site', 'docs', 'build'], - // 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 0214fd7d1d8..b667610c7f5 100644 --- a/packages/framework/config/hyde.php +++ b/packages/framework/config/hyde.php @@ -487,11 +487,6 @@ // 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), - // The list of directories that are considered to be safe to empty upon site build. - // If the site output directory is set to a directory that is not in this list, - // the build command will prompt for confirmation before emptying it. - 'safe_output_directories' => ['_site', 'docs', 'build'], - // Should a JSON build manifest with metadata about the build be generated? 'generate_build_manifest' => true, diff --git a/packages/framework/src/Console/Commands/BuildSiteCommand.php b/packages/framework/src/Console/Commands/BuildSiteCommand.php index b7d9eee337a..d0c332c9546 100644 --- a/packages/framework/src/Console/Commands/BuildSiteCommand.php +++ b/packages/framework/src/Console/Commands/BuildSiteCommand.php @@ -9,6 +9,7 @@ use Hyde\Facades\Config; use Hyde\Support\BuildWarnings; use Hyde\Framework\Actions\TransferStaticFiles; +use Hyde\Framework\Actions\Internal\OutputDirectoryValidator; use Hyde\Console\Concerns\Command; use Hyde\Framework\Services\BuildService; use Hyde\Framework\Services\BuildTaskService; @@ -80,6 +81,8 @@ protected function configureBuildTaskService(): void protected function runPreBuildActions(): void { + OutputDirectoryValidator::validate(); + if ($this->option('no-api')) { $this->info('Disabling external API calls'); $this->newLine(); diff --git a/packages/framework/src/Framework/Actions/Internal/OutputDirectoryValidator.php b/packages/framework/src/Framework/Actions/Internal/OutputDirectoryValidator.php new file mode 100644 index 00000000000..dc3028190d5 --- /dev/null +++ b/packages/framework/src/Framework/Actions/Internal/OutputDirectoryValidator.php @@ -0,0 +1,106 @@ + + */ + protected static function pathSegments(string $path): array + { + $segments = explode('/', normalize_slashes($path)); + + return array_values(array_filter($segments, fn (string $segment): bool => $segment !== '' && $segment !== '.')); + } + + /** @return array */ + protected static function protectedDirectories(): array + { + $directories = array_merge(static::PROJECT_DIRECTORIES, static::sourceDirectories(), [ + Hyde::getSourceRoot(), + Hyde::getMediaDirectory(), + '_static', + ]); + + return array_values(array_filter(array_map(fn (string $directory): string => implode('/', static::pathSegments($directory)), $directories))); + } + + /** @return array */ + protected static function sourceDirectories(): array + { + return array_map(fn (string $page): string => $page::sourceDirectory(), Hyde::getRegisteredPageClasses()); + } + + /** Compared case-insensitively, as case-insensitive filesystems would otherwise resolve a differently cased name to the same directory. */ + protected static function overlaps(string $directory, string $protected): bool + { + $directory = strtolower($directory); + $protected = strtolower($protected); + + return $directory === $protected + || str_starts_with($directory, $protected.'/') + || str_starts_with($protected, $directory.'/'); + } + + protected static function fail(string $message, string ...$values): never + { + throw new InvalidConfigurationException(sprintf($message, ...$values), 'hyde', 'output_directory'); + } +} diff --git a/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php b/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php index d4c279ddd33..0cfe735df38 100644 --- a/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php +++ b/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php @@ -5,24 +5,20 @@ namespace Hyde\Framework\Actions\PreBuildTasks; use Hyde\Hyde; -use Hyde\Facades\Config; use Hyde\Facades\Filesystem; -use Hyde\Support\Filesystem\MediaFile; +use Hyde\Framework\Actions\Internal\OutputDirectoryValidator; use Hyde\Framework\Features\BuildTasks\PreBuildTask; -use function basename; -use function in_array; -use function sprintf; - class CleanSiteDirectory extends PreBuildTask { protected static string $message = 'Removing all files from build directory'; public function handle(): void { - if ($this->isItSafeToCleanOutputDirectory()) { - Filesystem::unlink(Filesystem::findFiles(Hyde::sitePath(), ['html', 'json'])->all()); - Filesystem::cleanDirectory(MediaFile::outputPath()); + OutputDirectoryValidator::validate(); + + if (Filesystem::isDirectory(Hyde::sitePath())) { + Filesystem::cleanDirectory(Hyde::sitePath()); } } @@ -30,38 +26,4 @@ public function printFinishMessage(): void { $this->newLine(); } - - protected function isItSafeToCleanOutputDirectory(): bool - { - if (! $this->isOutputDirectoryWhitelisted() && ! $this->askIfUnsafeDirectoryShouldBeEmptied()) { - $this->info('Output directory will not be emptied.'); - - return false; - } - - return true; - } - - protected function isOutputDirectoryWhitelisted(): bool - { - return in_array(basename(Hyde::sitePath()), $this->safeOutputDirectories()); - } - - protected function askIfUnsafeDirectoryShouldBeEmptied(): bool - { - return $this->confirm(sprintf( - 'The configured output directory (%s) is potentially unsafe to empty. '. - 'Are you sure you want to continue?', - Hyde::getOutputDirectory() - )); - } - - /** @return array */ - protected function safeOutputDirectories(): array - { - /** @var array $directories */ - $directories = Config::getArray('hyde.safe_output_directories', ['_site', 'docs', 'build']); - - return $directories; - } } diff --git a/packages/framework/src/Framework/Services/BuildTaskService.php b/packages/framework/src/Framework/Services/BuildTaskService.php index 9b3589892cf..45c490898f1 100644 --- a/packages/framework/src/Framework/Services/BuildTaskService.php +++ b/packages/framework/src/Framework/Services/BuildTaskService.php @@ -129,16 +129,11 @@ protected function makeTaskIdentifier(BuildTask $class): string private function registerFrameworkTasks(): void { - $this->registerIf(CleanSiteDirectory::class, $this->canCleanSiteDirectory()); + $this->registerTask(CleanSiteDirectory::class); $this->registerIf(TransferMediaAssets::class, $this->canTransferMediaAssets()); $this->registerIf(GenerateBuildManifest::class, $this->canGenerateManifest()); } - private function canCleanSiteDirectory(): bool - { - return Config::getBool('hyde.empty_output_directory', true); - } - private function canTransferMediaAssets(): bool { return Config::getBool('hyde.transfer_media_assets', true); From e5b6c9bf027e0c409a8152411202601104a094f9 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 10 Aug 2026 20:59:06 +0200 Subject: [PATCH 2/7] Test deterministic output directory builds Covers dotfiles and nested directories being removed, static file output disappearing when its source is deleted, and the build failing when the output directory resolves outside the project. Co-Authored-By: Claude Opus 5 --- .../Feature/StaticFilePassthroughTest.php | 12 + .../tests/Feature/StaticSiteServiceTest.php | 231 ++++++++++++++++-- .../tests/Unit/BuildTaskServiceUnitTest.php | 35 +-- 3 files changed, 245 insertions(+), 33 deletions(-) diff --git a/packages/framework/tests/Feature/StaticFilePassthroughTest.php b/packages/framework/tests/Feature/StaticFilePassthroughTest.php index 9b856e5c59a..9e3bf2ce416 100644 --- a/packages/framework/tests/Feature/StaticFilePassthroughTest.php +++ b/packages/framework/tests/Feature/StaticFilePassthroughTest.php @@ -17,6 +17,7 @@ class StaticFilePassthroughTest extends TestCase protected function tearDown(): void { File::cleanDirectory(Hyde::sitePath()); + File::deleteDirectory(Hyde::path('_static')); parent::tearDown(); } @@ -54,6 +55,17 @@ public function testStaticFilesOverwriteTheirOutputFromThePreviousBuild(): void $this->assertSame('second', Filesystem::getContents('_site/robots.txt')); } + public function testDeletingAStaticFileRemovesItsOutputOnTheNextBuild(): void + { + $this->file('_static/robots.txt', 'User-agent: *'); + $this->artisan('build')->assertExitCode(0); + + Filesystem::unlink('_static/robots.txt'); + $this->artisan('build')->assertExitCode(0); + + $this->assertFileDoesNotExist(Hyde::sitePath('robots.txt')); + } + public function testStaticFilesCannotOverwriteGeneratedOutput(): void { $this->file('_static/a.txt', 'copied first without preflight'); diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index a2430623b5e..41edfb5d1fb 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -13,9 +13,13 @@ use Illuminate\Support\Facades\Process; use Hyde\Framework\HydeServiceProvider; use Hyde\Framework\Actions\StaticPageBuilder; +use Hyde\Framework\Exceptions\InvalidConfigurationException; +use Hyde\Framework\Actions\PreBuildTasks\CleanSiteDirectory; +use Hyde\Framework\Services\BuildTaskService; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSiteCommand::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Services\BuildService::class)] +#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\Internal\OutputDirectoryValidator::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PreBuildTasks\CleanSiteDirectory::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PreBuildTasks\TransferMediaAssets::class)] class StaticSiteServiceTest extends TestCase @@ -258,40 +262,197 @@ public function testGeneratesSearchFilesWhenConditionsAreMet() public function testSiteDirectoryIsEmptiedBeforeBuild() { Filesystem::touch('_site/foo.html'); + Filesystem::touch('_site/.nojekyll'); + Filesystem::ensureDirectoryExists(Hyde::path('_site/nested/deep')); + Filesystem::touch('_site/nested/deep/leftover.txt'); + $this->artisan('build') ->expectsOutputToContain('Removing all files from build directory...') ->assertExitCode(0); + $this->assertFileDoesNotExist(Hyde::path('_site/foo.html')); + $this->assertFileDoesNotExist(Hyde::path('_site/.nojekyll')); + $this->assertDirectoryDoesNotExist(Hyde::path('_site/nested')); } - public function testOutputDirectoryIsNotEmptiedIfDisabledInConfig() + public function testNonStandardOutputDirectoryIsEmptiedWithoutConfirmation() { - config(['hyde.empty_output_directory' => false]); - Filesystem::touch('_site/keep.html'); + Hyde::setOutputDirectory('foo'); + + mkdir(Hyde::path('foo')); + Filesystem::touch('foo/stale.html'); $this->artisan('build') - ->doesntExpectOutput('Removing all files from build directory...') + ->expectsOutputToContain('Removing all files from build directory...') ->assertExitCode(0); - $this->assertFileExists(Hyde::path('_site/keep.html')); - Filesystem::unlink('_site/keep.html'); + $this->assertFileDoesNotExist(Hyde::path('foo/stale.html')); + File::deleteDirectory(Hyde::path('foo')); } - public function testAbortsWhenNonStandardDirectoryIsEmptied() + public function testBuildFailsWhenOutputDirectoryIsTheProjectRoot() { - Hyde::setOutputDirectory('foo'); + Hyde::setOutputDirectory(''); - mkdir(Hyde::path('foo')); - Filesystem::touch('foo/keep.html'); + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage(sprintf( + 'The output directory (%s) must be a subdirectory of the project, as it is emptied before every build.', + Hyde::path() + )); - $this->artisan('build') - ->expectsOutputToContain('Removing all files from build directory...') - ->expectsQuestion('The configured output directory (foo) is potentially unsafe to empty. Are you sure you want to continue?', false) - ->expectsOutput('Output directory will not be emptied.') - ->assertExitCode(0); + $this->artisan('build')->run(); + } - $this->assertFileExists(Hyde::path('foo/keep.html')); - File::deleteDirectory(Hyde::path('foo')); + public function testBuildFailsWhenOutputDirectoryEscapesTheProject() + { + $sibling = Hyde::path('../hyde-output-outside-the-project'); + + Hyde::setOutputDirectory('../hyde-output-outside-the-project'); + + File::ensureDirectoryExists($sibling); + File::put($sibling.'/keep.txt', 'kept'); + + try { + $this->artisan('build')->run(); + $this->fail('The output directory outside the project was not rejected.'); + } catch (InvalidConfigurationException $exception) { + $this->assertStringContainsString('must be a subdirectory of the project', $exception->getMessage()); + } finally { + $this->assertFileExists($sibling.'/keep.txt'); + File::deleteDirectory($sibling); + } + } + + public function testBuildDoesNotCreateAnOutputDirectoryOutsideTheProject() + { + $sibling = Hyde::path('../hyde-output-outside-the-project'); + + Hyde::setOutputDirectory('../hyde-output-outside-the-project'); + + $this->expectException(InvalidConfigurationException::class); + + try { + $this->artisan('build')->run(); + } finally { + $created = File::isDirectory($sibling); + File::deleteDirectory($sibling); + + $this->assertFalse($created, 'The output directory was created outside the project.'); + } + } + + public function testBuildFailsWhenTheOutputDirectoryIsASymbolicLink() + { + $target = $this->createSymlinkedOutputDirectory(); + + try { + $this->artisan('build')->run(); + $this->fail('The symbolic link output directory was not rejected.'); + } catch (InvalidConfigurationException $exception) { + $this->assertStringContainsString('must not be a symbolic link', $exception->getMessage()); + } finally { + $this->assertTrue($this->removeSymlinkedOutputDirectory($target), 'The symlink target was emptied.'); + } + } + + public function testBuildFailsWhenTheOutputDirectoryIsBehindASymbolicLink() + { + $outside = Hyde::path('../hyde-output-outside-the-project'); + + File::ensureDirectoryExists($outside); + symlink($outside, Hyde::path('_test-symlink-escape')); + + Hyde::setOutputDirectory('_test-symlink-escape/site'); + + try { + $this->artisan('build')->run(); + $this->fail('The output directory behind a symbolic link was not rejected.'); + } catch (InvalidConfigurationException $exception) { + $this->assertStringContainsString('must not be a symbolic link', $exception->getMessage()); + } finally { + $this->removeSymlink(Hyde::path('_test-symlink-escape')); + + $created = File::isDirectory($outside.'/site'); + File::deleteDirectory($outside); + + $this->assertFalse($created, 'The site was created outside the project.'); + } + } + + public function testCleanSiteDirectoryTaskValidatesTheOutputDirectoryBeforeEmptyingIt() + { + $target = $this->createSymlinkedOutputDirectory(); + + try { + (new CleanSiteDirectory())->handle(); + $this->fail('The symbolic link output directory was not rejected.'); + } catch (InvalidConfigurationException $exception) { + $this->assertStringContainsString('must not be a symbolic link', $exception->getMessage()); + } finally { + $this->assertTrue($this->removeSymlinkedOutputDirectory($target), 'The symlink target was emptied.'); + } + } + + #[\PHPUnit\Framework\Attributes\DataProvider('protectedOutputDirectories')] + public function testBuildFailsWhenTheOutputDirectoryOverlapsAProjectDirectory(string $directory) + { + Hyde::setOutputDirectory($directory); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage(sprintf('The output directory (%s) must not overlap the project directory', $directory)); + + $this->artisan('build')->run(); + } + + public static function protectedOutputDirectories(): array + { + return [ + 'version control' => ['.git'], + 'application code' => ['app'], + 'nested in application code' => ['app/storage'], + 'configuration' => ['config'], + 'dependencies' => ['vendor'], + 'page sources' => ['_pages'], + 'post sources' => ['_posts'], + 'media sources' => ['_media'], + 'static files' => ['_static'], + 'uppercase application code' => ['APP'], + 'mixed case dependencies' => ['Vendor'], + 'uppercase page sources' => ['_PAGES'], + 'uppercase static files' => ['_STATIC'], + ]; + } + + public function testBuildFailsWhenTheOutputDirectoryMatchesADifferentlySpelledProjectDirectory() + { + Hyde::setMediaDirectory('./assets'); + Hyde::setOutputDirectory('assets'); + + $this->expectException(InvalidConfigurationException::class); + + $this->artisan('build')->run(); + } + + public function testBuildTaskServiceCannotEmptyAProjectDirectory() + { + $this->file('_static/keep.txt', 'kept'); + + Hyde::setOutputDirectory('_static'); + + (new BuildTaskService())->runPreBuildTasks(); + + $this->assertFileExists(Hyde::path('_static/keep.txt')); + } + + public function testBuildCreatesANestedOutputDirectoryThatDoesNotExistYet() + { + Hyde::setOutputDirectory('build/nested-site'); + + $this->artisan('build')->assertExitCode(0); + + $this->assertFileExists(Hyde::path('build/nested-site/index.html')); + + File::deleteDirectory(Hyde::path('build')); } public function testWithoutWarnings() @@ -460,4 +621,40 @@ public function testNormalTransferWhenMultipleAssetsExistAndLoadAppStylesFromCdn $this->assertFileDoesNotExist(Hyde::path('_site/media/app.css')); $this->assertFileExists(Hyde::path('_site/media/image.png')); } + + /** Point the output directory at a symbolic link leading to a directory holding a file that must survive. */ + protected function createSymlinkedOutputDirectory(): string + { + $target = Hyde::path('_test-symlink-target'); + + File::ensureDirectoryExists($target); + File::put($target.'/keep.txt', 'kept'); + symlink($target, Hyde::path('_test-symlink-output')); + + Hyde::setOutputDirectory('_test-symlink-output'); + + return $target; + } + + /** @return bool Whether the file behind the symbolic link survived. */ + protected function removeSymlinkedOutputDirectory(string $target): bool + { + $this->removeSymlink(Hyde::path('_test-symlink-output')); + + $survived = File::exists($target.'/keep.txt'); + + File::deleteDirectory($target); + + return $survived; + } + + /** Windows needs rmdir to remove a symbolic link to a directory, which leaves the target alone just like unlink does. */ + protected function removeSymlink(string $path): void + { + if (DIRECTORY_SEPARATOR === '\\' && is_dir($path)) { + rmdir($path); + } else { + unlink($path); + } + } } diff --git a/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php b/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php index 5ef62223993..fcd834140e6 100644 --- a/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php +++ b/packages/framework/tests/Unit/BuildTaskServiceUnitTest.php @@ -8,6 +8,7 @@ use Hyde\Foundation\HydeKernel; use Hyde\Foundation\Kernel\Filesystem; use Hyde\Framework\Actions\PostBuildTasks\GenerateBuildManifest as FrameworkGenerateBuildManifest; +use Hyde\Framework\Actions\PreBuildTasks\CleanSiteDirectory; use Hyde\Framework\Actions\PreBuildTasks\TransferMediaAssets; use Hyde\Framework\Features\BuildTasks\BuildTask; use Hyde\Framework\Features\BuildTasks\PostBuildTask; @@ -15,6 +16,8 @@ use Hyde\Framework\Services\BuildTaskService; use Hyde\Testing\UnitTestCase; use Illuminate\Console\OutputStyle; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\NullOutput; use Mockery; use ReflectionClass; use stdClass; @@ -33,7 +36,6 @@ class BuildTaskServiceUnitTest extends UnitTestCase protected function setUp(): void { self::mockConfig(['hyde' => [ - 'empty_output_directory' => false, 'generate_build_manifest' => false, 'transfer_media_assets' => false, ]]); @@ -53,49 +55,49 @@ public function testConstruct() public function testGetTasks() { - $this->assertSame([], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class], $this->service->getRegisteredTasks()); } public function testGetTasksWithTaskRegisteredInConfig() { self::mockConfig(array_merge(config()->all(), ['hyde.build_tasks' => [TestBuildTask::class]])); - $this->assertSame([TestBuildTask::class], $this->createService()->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class, TestBuildTask::class], $this->createService()->getRegisteredTasks()); } public function testRegisterTask() { $this->service->registerTask(TestBuildTask::class); - $this->assertSame([TestBuildTask::class], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class, TestBuildTask::class], $this->service->getRegisteredTasks()); } public function testRegisterPreBuildTask() { $this->service->registerTask(TestPreBuildTask::class); - $this->assertSame([TestPreBuildTask::class], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class, TestPreBuildTask::class], $this->service->getRegisteredTasks()); } public function testRegisterPostBuildTask() { $this->service->registerTask(TestPostBuildTask::class); - $this->assertSame([TestPostBuildTask::class], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class, TestPostBuildTask::class], $this->service->getRegisteredTasks()); } public function testRegisterInstantiatedTask() { $this->service->registerTask(new TestBuildTask()); - $this->assertSame([TestBuildTask::class], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class, TestBuildTask::class], $this->service->getRegisteredTasks()); } public function testRegisterInstantiatedPreBuildTask() { $this->service->registerTask(new TestPreBuildTask()); - $this->assertSame([TestPreBuildTask::class], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class, TestPreBuildTask::class], $this->service->getRegisteredTasks()); } public function testRegisterInstantiatedPostBuildTask() { $this->service->registerTask(new TestPostBuildTask()); - $this->assertSame([TestPostBuildTask::class], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class, TestPostBuildTask::class], $this->service->getRegisteredTasks()); } public function testRegisterTaskWithInvalidClassTypeThrowsException() @@ -121,7 +123,7 @@ public function testRegisterTaskWithAlreadyRegisteredTask() $this->service->registerTask(TestBuildTask::class); $this->service->registerTask(TestBuildTask::class); - $this->assertSame([TestBuildTask::class], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class, TestBuildTask::class], $this->service->getRegisteredTasks()); } public function testRegisterTaskWithTaskAlreadyRegisteredInConfig() @@ -130,13 +132,13 @@ public function testRegisterTaskWithTaskAlreadyRegisteredInConfig() $this->createService(); $this->service->registerTask(TestBuildTask::class); - $this->assertSame([TestBuildTask::class], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class, TestBuildTask::class], $this->service->getRegisteredTasks()); } public function testCanRegisterFrameworkTasks() { $this->service->registerTask(FrameworkGenerateBuildManifest::class); - $this->assertSame([FrameworkGenerateBuildManifest::class], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class, FrameworkGenerateBuildManifest::class], $this->service->getRegisteredTasks()); } public function testCanOverloadFrameworkTasks() @@ -144,7 +146,7 @@ public function testCanOverloadFrameworkTasks() $this->service->registerTask(FrameworkGenerateBuildManifest::class); $this->service->registerTask(GenerateBuildManifest::class); - $this->assertSame([GenerateBuildManifest::class], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class, GenerateBuildManifest::class], $this->service->getRegisteredTasks()); } public function testCanSetOutputWithNull() @@ -262,7 +264,7 @@ public function testServiceSearchesForTasksInAppDirectory() $this->can($this->createService(...)); - $this->assertSame([], $this->service->getRegisteredTasks()); + $this->assertSame([CleanSiteDirectory::class], $this->service->getRegisteredTasks()); $this->resetKernelInstance(); } @@ -279,6 +281,7 @@ public function testServiceFindsTasksInAppDirectory() $this->can($this->createService(...)); $this->assertSame([ + 'Hyde\Framework\Actions\PreBuildTasks\CleanSiteDirectory', 'Hyde\Framework\Actions\PostBuildTasks\GenerateBuildManifest', 'Hyde\Framework\Actions\PreBuildTasks\TransferMediaAssets', ], $this->service->getRegisteredTasks()); @@ -319,9 +322,9 @@ protected function setupMock(string $class, string $method): Mockery\Expectation return Mockery::mock($class)->makePartial()->shouldReceive($method)->once(); } - protected function mockOutput(): Mockery\LegacyMockInterface|Mockery\MockInterface|OutputStyle + protected function mockOutput(): OutputStyle { - return Mockery::mock(OutputStyle::class)->makePartial(); + return new OutputStyle(new ArrayInput([]), new NullOutput()); } } From 5f76de46117486948216338ff3571474f753d29e Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 10 Aug 2026 21:01:14 +0200 Subject: [PATCH 3/7] Document the deterministic output directory Co-Authored-By: Claude Opus 5 --- HYDEPHP_V3_PLANNING.md | 3 +++ UPGRADE.md | 9 +++++++++ docs/creating-content/managing-assets.md | 2 ++ docs/digging-deeper/advanced-customization.md | 2 +- docs/digging-deeper/customization.md | 10 ---------- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index aecb40c2742..3b9b041f058 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -67,6 +67,8 @@ Having this document in code lets us know the devlopment state at any given poin - Removed the `components/filepath-label.blade.php` view. The label markup now lives in `components/markdown/code-block.blade.php` alongside the rest of what surrounds the code. **A published copy of the old view is ignored after upgrading**, and the site renders with the shipped label until the customizations are ported over. That is the intended outcome: published views take precedence over the framework's own, and a copy written for the label's old position inside `` places it outside the code block entirely, so keeping the view in use could have produced incorrect layouts for those customized copies. - Removed the `rebuild` command (`RebuildPageCommand`). It was originally added to build a single file to disk before the realtime compiler existed, and later used internally by the RC to build-and-serve a path, but the RC now renders everything in-memory, leaving `rebuild` with no remaining consumer. It also had no safe user-facing use case: a single-page build only produces a correct `_site` when the page is self-contained, while a page change routinely invalidates aggregate outputs (sitemap, RSS, search index, post listings, navigation), so single-path building could silently leave a stale output directory that looked complete. The underlying single-page build capability remains available internally via the `StaticPageBuilder` action. ([#2490](https://github.com/hydephp/develop/pull/2490)) +- The site output directory is now emptied completely before every build. In v2 the build only removed HTML and JSON files along with the media directory, so any other file left in the output directory survived indefinitely, including output whose source file had since been deleted. Now that `_static` provides a source-controlled home for arbitrary root-level files, the compiled site is treated as disposable build output that is recreated from source on every build, which is also what a clean CI or GitHub Pages build has always produced. Files that must be present in the compiled site, such as `CNAME`, `.nojekyll`, and search engine verification files, belong in `_static`. The `hyde.empty_output_directory` and `hyde.safe_output_directories` options are removed. The safelist and its confirmation prompt existed to keep the build from emptying a directory the user did not mean to hand over, and that safeguard is not gone but replaced: the build now fails with a configuration error, without prompting, when the output directory is the project root, escapes the project, is reached through a symbolic link, or overlaps a directory the project needs, like `app`, `vendor`, or a configured source directory. Being non-interactive, the check also works in CI, and it runs before the build writes anything, so it covers creating a site in the wrong place as well as emptying one. + ### Upgrade guide Please fill in UPGRADE.md as you make changes. @@ -84,6 +86,7 @@ Please fill in UPGRADE.md as you make changes. - Replace `// filepath:` code block comments with the `title="…"` fence modifier, including the `#`, `/* */`, and `` comment variants. - 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`. ## `InMemoryPage` content-source motivation diff --git a/UPGRADE.md b/UPGRADE.md index 8c7defe4787..d0b720ee3da 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -431,6 +431,14 @@ To find affected posts, search `_posts` for `draft: true`, and check for dates a If a post that was supposed to be published turns out to be excluded, remove the `draft` property or correct the date. If you actually want to schedule posts, remember that **Hyde is a static site generator**: a scheduled post does not publish itself when its date passes. It is included in the first site build that runs after that point, so you need recurring builds for a post to go live on its own, for example a cron-scheduled GitHub Actions workflow. +## Step 12: Move Manually Maintained Output Files Into `_static` + +HydePHP v3 empties the entire output directory before every build. In v2 the build only removed HTML and JSON files along with the media directory, so anything else you put in `_site` stayed there between builds. Files like `CNAME`, `.nojekyll`, and search engine verification files were commonly committed straight into the output directory for that reason. + +Move those files into the `_static` directory, which is copied verbatim to the site root on every build, so `_static/CNAME` becomes `_site/CNAME`. + +The `hyde.empty_output_directory` and `hyde.safe_output_directories` options no longer exist, and the build no longer asks for confirmation before emptying an unfamiliar output directory. Delete either entry if your `config/hyde.php` sets it. The build now fails with a configuration error if `hyde.output_directory` points outside your project, or at a directory the project needs, such as `app`, `vendor`, or one of your source directories. + ## Migration Checklist Use this checklist to track your upgrade progress: @@ -447,6 +455,7 @@ Use this checklist to track your upgrade progress: - [ ] Ported any `filepath-label.blade.php` customizations to `markdown/code-block.blade.php`, and deleted the old file - [ ] 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` ## Troubleshooting diff --git a/docs/creating-content/managing-assets.md b/docs/creating-content/managing-assets.md index 08de9dcf872..a492bd7ed26 100644 --- a/docs/creating-content/managing-assets.md +++ b/docs/creating-content/managing-assets.md @@ -20,6 +20,8 @@ To get you started quickly, all the styles are already compiled and minified int 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`. +Since the output directory is emptied before every build, files like `CNAME` and `.nojekyll` need to live in `_static` rather than being placed in the compiled site directly. + ## 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. diff --git a/docs/digging-deeper/advanced-customization.md b/docs/digging-deeper/advanced-customization.md index 68f284215e8..2f120e612c7 100644 --- a/docs/digging-deeper/advanced-customization.md +++ b/docs/digging-deeper/advanced-customization.md @@ -159,7 +159,7 @@ from the output directory, so files in `_assets` will be copied to `_site/assets >danger

Warning: Hyde deletes all files in the output directory before compiling the site.

Don't set this path to a directory that contains important files!

If you want to store your compiled website in a different directory than the default `_site`, you can change the path -using the following configuration option in `config/hyde.php`. The path is expected to be relative to your project root. +using the following configuration option in `config/hyde.php`. The path must be a subdirectory of your project root. ```php title="config/hyde.php" 'output_directory' => '_site', diff --git a/docs/digging-deeper/customization.md b/docs/digging-deeper/customization.md index 3df87d59ea8..62d4a563c19 100644 --- a/docs/digging-deeper/customization.md +++ b/docs/digging-deeper/customization.md @@ -365,16 +365,6 @@ use \Hyde\Support\Filesystem\MediaFile; 'media_extensions' => array_merge([], MediaFile::EXTENSIONS), ``` -### `safe_output_directories` - -This setting defines a list of directories deemed safe to empty during the site build process as a safeguard to prevent accidental data loss. -If the site output directory is not in this list, the build command will prompt for confirmation before emptying it. It is preconfigured -with common directories including the default one, but you are free to change this to include any custom directories you may need. - -```php title="config/hyde.php" -'safe_output_directories' => ['_site', 'docs', 'build'], -``` - ### `generate_build_manifest` Determines whether a JSON build manifest with metadata about the build should be generated. Set to `true` to enable. From 01894ca998c3caa1964740fa8ed7bfd3a992399f Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 11 Aug 2026 02:03:35 +0200 Subject: [PATCH 4/7] Guard only against emptying the project itself The output directory guard rejected symbolic links, upwards traversal, and any overlap with a list of project directories. Building outside the project is a real use case, such as a Hyde project in a home directory compiling into the server's public directory, and the symlink check broke builds pointing the output directory at a deploy target. Choosing an output directory that holds other files is a deliberate setting, so the build no longer second-guesses it. The project root is different: it is what an unfilled value resolves to rather than a directory anyone means to hand over, and emptying it destroys the project instead of the site. That leaves it as the only case worth checking, so it is checked where the build can still abort on it. The guard also ran in CleanSiteDirectory, where BuildTask::run() catches the exception, so it printed a failure and let the build continue with exit code 0. Only the command can abort, so the check lives there alone. Co-Authored-By: Claude Opus 5 --- .../src/Console/Commands/BuildSiteCommand.php | 18 +- .../Internal/OutputDirectoryValidator.php | 106 ----------- .../PreBuildTasks/CleanSiteDirectory.php | 3 - .../tests/Feature/StaticSiteServiceTest.php | 180 +----------------- 4 files changed, 19 insertions(+), 288 deletions(-) delete mode 100644 packages/framework/src/Framework/Actions/Internal/OutputDirectoryValidator.php diff --git a/packages/framework/src/Console/Commands/BuildSiteCommand.php b/packages/framework/src/Console/Commands/BuildSiteCommand.php index d0c332c9546..20536e22d8d 100644 --- a/packages/framework/src/Console/Commands/BuildSiteCommand.php +++ b/packages/framework/src/Console/Commands/BuildSiteCommand.php @@ -9,17 +9,19 @@ use Hyde\Facades\Config; use Hyde\Support\BuildWarnings; use Hyde\Framework\Actions\TransferStaticFiles; -use Hyde\Framework\Actions\Internal\OutputDirectoryValidator; +use Hyde\Framework\Exceptions\InvalidConfigurationException; use Hyde\Console\Concerns\Command; use Hyde\Framework\Services\BuildService; use Hyde\Framework\Services\BuildTaskService; use Illuminate\Support\Facades\Process; +use function Hyde\normalize_slashes; use function memory_get_peak_usage; use function number_format; use function array_search; use function microtime; use function sprintf; +use function trim; use function app; /** @@ -81,7 +83,7 @@ protected function configureBuildTaskService(): void protected function runPreBuildActions(): void { - OutputDirectoryValidator::validate(); + $this->assertOutputDirectoryIsSafeToEmpty(); if ($this->option('no-api')) { $this->info('Disabling external API calls'); @@ -110,6 +112,18 @@ public function runPostBuildActions(): void $this->taskService->runPostBuildTasks(); } + protected function assertOutputDirectoryIsSafeToEmpty(): void + { + $directory = trim(normalize_slashes(Hyde::getOutputDirectory()), '/'); + + if ($directory === '' || $directory === '.') { + throw new InvalidConfigurationException( + 'The output directory must not be the project root, as it is emptied before every build.', + 'hyde', 'output_directory' + ); + } + } + protected function printFinishMessage(float $timeStart): void { if ($this->hasWarnings()) { diff --git a/packages/framework/src/Framework/Actions/Internal/OutputDirectoryValidator.php b/packages/framework/src/Framework/Actions/Internal/OutputDirectoryValidator.php deleted file mode 100644 index dc3028190d5..00000000000 --- a/packages/framework/src/Framework/Actions/Internal/OutputDirectoryValidator.php +++ /dev/null @@ -1,106 +0,0 @@ - - */ - protected static function pathSegments(string $path): array - { - $segments = explode('/', normalize_slashes($path)); - - return array_values(array_filter($segments, fn (string $segment): bool => $segment !== '' && $segment !== '.')); - } - - /** @return array */ - protected static function protectedDirectories(): array - { - $directories = array_merge(static::PROJECT_DIRECTORIES, static::sourceDirectories(), [ - Hyde::getSourceRoot(), - Hyde::getMediaDirectory(), - '_static', - ]); - - return array_values(array_filter(array_map(fn (string $directory): string => implode('/', static::pathSegments($directory)), $directories))); - } - - /** @return array */ - protected static function sourceDirectories(): array - { - return array_map(fn (string $page): string => $page::sourceDirectory(), Hyde::getRegisteredPageClasses()); - } - - /** Compared case-insensitively, as case-insensitive filesystems would otherwise resolve a differently cased name to the same directory. */ - protected static function overlaps(string $directory, string $protected): bool - { - $directory = strtolower($directory); - $protected = strtolower($protected); - - return $directory === $protected - || str_starts_with($directory, $protected.'/') - || str_starts_with($protected, $directory.'/'); - } - - protected static function fail(string $message, string ...$values): never - { - throw new InvalidConfigurationException(sprintf($message, ...$values), 'hyde', 'output_directory'); - } -} diff --git a/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php b/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php index 0cfe735df38..633f302bc64 100644 --- a/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php +++ b/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php @@ -6,7 +6,6 @@ use Hyde\Hyde; use Hyde\Facades\Filesystem; -use Hyde\Framework\Actions\Internal\OutputDirectoryValidator; use Hyde\Framework\Features\BuildTasks\PreBuildTask; class CleanSiteDirectory extends PreBuildTask @@ -15,8 +14,6 @@ class CleanSiteDirectory extends PreBuildTask public function handle(): void { - OutputDirectoryValidator::validate(); - if (Filesystem::isDirectory(Hyde::sitePath())) { Filesystem::cleanDirectory(Hyde::sitePath()); } diff --git a/packages/framework/tests/Feature/StaticSiteServiceTest.php b/packages/framework/tests/Feature/StaticSiteServiceTest.php index 41edfb5d1fb..fd4a21c0071 100644 --- a/packages/framework/tests/Feature/StaticSiteServiceTest.php +++ b/packages/framework/tests/Feature/StaticSiteServiceTest.php @@ -14,12 +14,9 @@ use Hyde\Framework\HydeServiceProvider; use Hyde\Framework\Actions\StaticPageBuilder; use Hyde\Framework\Exceptions\InvalidConfigurationException; -use Hyde\Framework\Actions\PreBuildTasks\CleanSiteDirectory; -use Hyde\Framework\Services\BuildTaskService; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\BuildSiteCommand::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Services\BuildService::class)] -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\Internal\OutputDirectoryValidator::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PreBuildTasks\CleanSiteDirectory::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Actions\PreBuildTasks\TransferMediaAssets::class)] class StaticSiteServiceTest extends TestCase @@ -295,155 +292,20 @@ public function testBuildFailsWhenOutputDirectoryIsTheProjectRoot() Hyde::setOutputDirectory(''); $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage(sprintf( - 'The output directory (%s) must be a subdirectory of the project, as it is emptied before every build.', - Hyde::path() - )); + $this->expectExceptionMessage('The output directory must not be the project root, as it is emptied before every build.'); $this->artisan('build')->run(); } - public function testBuildFailsWhenOutputDirectoryEscapesTheProject() + public function testBuildFailsWhenOutputDirectoryIsExplicitlyTheProjectRoot() { - $sibling = Hyde::path('../hyde-output-outside-the-project'); - - Hyde::setOutputDirectory('../hyde-output-outside-the-project'); - - File::ensureDirectoryExists($sibling); - File::put($sibling.'/keep.txt', 'kept'); - - try { - $this->artisan('build')->run(); - $this->fail('The output directory outside the project was not rejected.'); - } catch (InvalidConfigurationException $exception) { - $this->assertStringContainsString('must be a subdirectory of the project', $exception->getMessage()); - } finally { - $this->assertFileExists($sibling.'/keep.txt'); - File::deleteDirectory($sibling); - } - } - - public function testBuildDoesNotCreateAnOutputDirectoryOutsideTheProject() - { - $sibling = Hyde::path('../hyde-output-outside-the-project'); - - Hyde::setOutputDirectory('../hyde-output-outside-the-project'); + Hyde::setOutputDirectory('.'); $this->expectException(InvalidConfigurationException::class); - try { - $this->artisan('build')->run(); - } finally { - $created = File::isDirectory($sibling); - File::deleteDirectory($sibling); - - $this->assertFalse($created, 'The output directory was created outside the project.'); - } - } - - public function testBuildFailsWhenTheOutputDirectoryIsASymbolicLink() - { - $target = $this->createSymlinkedOutputDirectory(); - - try { - $this->artisan('build')->run(); - $this->fail('The symbolic link output directory was not rejected.'); - } catch (InvalidConfigurationException $exception) { - $this->assertStringContainsString('must not be a symbolic link', $exception->getMessage()); - } finally { - $this->assertTrue($this->removeSymlinkedOutputDirectory($target), 'The symlink target was emptied.'); - } - } - - public function testBuildFailsWhenTheOutputDirectoryIsBehindASymbolicLink() - { - $outside = Hyde::path('../hyde-output-outside-the-project'); - - File::ensureDirectoryExists($outside); - symlink($outside, Hyde::path('_test-symlink-escape')); - - Hyde::setOutputDirectory('_test-symlink-escape/site'); - - try { - $this->artisan('build')->run(); - $this->fail('The output directory behind a symbolic link was not rejected.'); - } catch (InvalidConfigurationException $exception) { - $this->assertStringContainsString('must not be a symbolic link', $exception->getMessage()); - } finally { - $this->removeSymlink(Hyde::path('_test-symlink-escape')); - - $created = File::isDirectory($outside.'/site'); - File::deleteDirectory($outside); - - $this->assertFalse($created, 'The site was created outside the project.'); - } - } - - public function testCleanSiteDirectoryTaskValidatesTheOutputDirectoryBeforeEmptyingIt() - { - $target = $this->createSymlinkedOutputDirectory(); - - try { - (new CleanSiteDirectory())->handle(); - $this->fail('The symbolic link output directory was not rejected.'); - } catch (InvalidConfigurationException $exception) { - $this->assertStringContainsString('must not be a symbolic link', $exception->getMessage()); - } finally { - $this->assertTrue($this->removeSymlinkedOutputDirectory($target), 'The symlink target was emptied.'); - } - } - - #[\PHPUnit\Framework\Attributes\DataProvider('protectedOutputDirectories')] - public function testBuildFailsWhenTheOutputDirectoryOverlapsAProjectDirectory(string $directory) - { - Hyde::setOutputDirectory($directory); - - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage(sprintf('The output directory (%s) must not overlap the project directory', $directory)); - $this->artisan('build')->run(); } - public static function protectedOutputDirectories(): array - { - return [ - 'version control' => ['.git'], - 'application code' => ['app'], - 'nested in application code' => ['app/storage'], - 'configuration' => ['config'], - 'dependencies' => ['vendor'], - 'page sources' => ['_pages'], - 'post sources' => ['_posts'], - 'media sources' => ['_media'], - 'static files' => ['_static'], - 'uppercase application code' => ['APP'], - 'mixed case dependencies' => ['Vendor'], - 'uppercase page sources' => ['_PAGES'], - 'uppercase static files' => ['_STATIC'], - ]; - } - - public function testBuildFailsWhenTheOutputDirectoryMatchesADifferentlySpelledProjectDirectory() - { - Hyde::setMediaDirectory('./assets'); - Hyde::setOutputDirectory('assets'); - - $this->expectException(InvalidConfigurationException::class); - - $this->artisan('build')->run(); - } - - public function testBuildTaskServiceCannotEmptyAProjectDirectory() - { - $this->file('_static/keep.txt', 'kept'); - - Hyde::setOutputDirectory('_static'); - - (new BuildTaskService())->runPreBuildTasks(); - - $this->assertFileExists(Hyde::path('_static/keep.txt')); - } - public function testBuildCreatesANestedOutputDirectoryThatDoesNotExistYet() { Hyde::setOutputDirectory('build/nested-site'); @@ -621,40 +483,4 @@ public function testNormalTransferWhenMultipleAssetsExistAndLoadAppStylesFromCdn $this->assertFileDoesNotExist(Hyde::path('_site/media/app.css')); $this->assertFileExists(Hyde::path('_site/media/image.png')); } - - /** Point the output directory at a symbolic link leading to a directory holding a file that must survive. */ - protected function createSymlinkedOutputDirectory(): string - { - $target = Hyde::path('_test-symlink-target'); - - File::ensureDirectoryExists($target); - File::put($target.'/keep.txt', 'kept'); - symlink($target, Hyde::path('_test-symlink-output')); - - Hyde::setOutputDirectory('_test-symlink-output'); - - return $target; - } - - /** @return bool Whether the file behind the symbolic link survived. */ - protected function removeSymlinkedOutputDirectory(string $target): bool - { - $this->removeSymlink(Hyde::path('_test-symlink-output')); - - $survived = File::exists($target.'/keep.txt'); - - File::deleteDirectory($target); - - return $survived; - } - - /** Windows needs rmdir to remove a symbolic link to a directory, which leaves the target alone just like unlink does. */ - protected function removeSymlink(string $path): void - { - if (DIRECTORY_SEPARATOR === '\\' && is_dir($path)) { - rmdir($path); - } else { - unlink($path); - } - } } From bf31ff69e51e1098aad33b73e30a50af8a46be15 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 11 Aug 2026 02:03:47 +0200 Subject: [PATCH 5/7] Correct the output directory documentation An absolute output directory is resolved relative to the project root rather than rejected, so describing the path as one that must be a subdirectory of the project was misleading for the case users would hit. Name CNAME alongside the existing _static examples instead of arguing against keeping it in the compiled site, which is a practice only existing users have, and one the upgrade guide already covers. Co-Authored-By: Claude Opus 5 --- docs/creating-content/managing-assets.md | 4 +--- docs/digging-deeper/advanced-customization.md | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/creating-content/managing-assets.md b/docs/creating-content/managing-assets.md index a492bd7ed26..e71d09bdf10 100644 --- a/docs/creating-content/managing-assets.md +++ b/docs/creating-content/managing-assets.md @@ -18,9 +18,7 @@ To get you started quickly, all the styles are already compiled and minified int ## 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`. - -Since the output directory is emptied before every build, files like `CNAME` and `.nojekyll` need to live in `_static` rather than being placed in the compiled site directly. +Files that need to be published directly to the site root can be placed in an optional `_static` directory. Paths are preserved, so `_static/CNAME` becomes `_site/CNAME`, and `_static/.well-known/security.txt` becomes `_site/.well-known/security.txt`. Use `_media` for normal site assets published under `/media`. ## Vite diff --git a/docs/digging-deeper/advanced-customization.md b/docs/digging-deeper/advanced-customization.md index 2f120e612c7..68f284215e8 100644 --- a/docs/digging-deeper/advanced-customization.md +++ b/docs/digging-deeper/advanced-customization.md @@ -159,7 +159,7 @@ from the output directory, so files in `_assets` will be copied to `_site/assets >danger

Warning: Hyde deletes all files in the output directory before compiling the site.

Don't set this path to a directory that contains important files!

If you want to store your compiled website in a different directory than the default `_site`, you can change the path -using the following configuration option in `config/hyde.php`. The path must be a subdirectory of your project root. +using the following configuration option in `config/hyde.php`. The path is expected to be relative to your project root. ```php title="config/hyde.php" 'output_directory' => '_site', From ad4dc937f6a4c5660a5936591e646478bd2ea2dc Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 11 Aug 2026 02:04:22 +0200 Subject: [PATCH 6/7] Describe the realistic impact of the output directory change The upgrade step prescribed deleting `hyde.empty_output_directory` from a published config file, but that option was never shipped in `config/hyde.php` and never documented, so no user has an entry to delete. The remaining instructions promised a configuration error for any output directory outside the project or overlapping one the project needs, which overstates what the build checks. Co-Authored-By: Claude Opus 5 --- HYDEPHP_V3_PLANNING.md | 6 +++++- UPGRADE.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/HYDEPHP_V3_PLANNING.md b/HYDEPHP_V3_PLANNING.md index 3b9b041f058..9555eb19fbc 100644 --- a/HYDEPHP_V3_PLANNING.md +++ b/HYDEPHP_V3_PLANNING.md @@ -67,7 +67,11 @@ Having this document in code lets us know the devlopment state at any given poin - Removed the `components/filepath-label.blade.php` view. The label markup now lives in `components/markdown/code-block.blade.php` alongside the rest of what surrounds the code. **A published copy of the old view is ignored after upgrading**, and the site renders with the shipped label until the customizations are ported over. That is the intended outcome: published views take precedence over the framework's own, and a copy written for the label's old position inside `` places it outside the code block entirely, so keeping the view in use could have produced incorrect layouts for those customized copies. - Removed the `rebuild` command (`RebuildPageCommand`). It was originally added to build a single file to disk before the realtime compiler existed, and later used internally by the RC to build-and-serve a path, but the RC now renders everything in-memory, leaving `rebuild` with no remaining consumer. It also had no safe user-facing use case: a single-page build only produces a correct `_site` when the page is self-contained, while a page change routinely invalidates aggregate outputs (sitemap, RSS, search index, post listings, navigation), so single-path building could silently leave a stale output directory that looked complete. The underlying single-page build capability remains available internally via the `StaticPageBuilder` action. ([#2490](https://github.com/hydephp/develop/pull/2490)) -- The site output directory is now emptied completely before every build. In v2 the build only removed HTML and JSON files along with the media directory, so any other file left in the output directory survived indefinitely, including output whose source file had since been deleted. Now that `_static` provides a source-controlled home for arbitrary root-level files, the compiled site is treated as disposable build output that is recreated from source on every build, which is also what a clean CI or GitHub Pages build has always produced. Files that must be present in the compiled site, such as `CNAME`, `.nojekyll`, and search engine verification files, belong in `_static`. The `hyde.empty_output_directory` and `hyde.safe_output_directories` options are removed. The safelist and its confirmation prompt existed to keep the build from emptying a directory the user did not mean to hand over, and that safeguard is not gone but replaced: the build now fails with a configuration error, without prompting, when the output directory is the project root, escapes the project, is reached through a symbolic link, or overlaps a directory the project needs, like `app`, `vendor`, or a configured source directory. Being non-interactive, the check also works in CI, and it runs before the build writes anything, so it covers creating a site in the wrong place as well as emptying one. +- The site output directory is now emptied completely before every build. In v2 the build only removed HTML and JSON files along with the media directory, so any other file left in the output directory survived indefinitely, including output whose source file had since been deleted. Now that `_static` provides a source-controlled home for arbitrary root-level files, the compiled site is treated as disposable build output that is recreated from source on every build, which is also what a clean CI or GitHub Pages build has always produced. Files that must be present in the compiled site, such as `CNAME`, `.nojekyll`, and search engine verification files, belong in `_static`. + + The `hyde.safe_output_directories` option is removed along with the confirmation prompt it gated. The prompt was a poor fit for total emptying, since it matched on the directory basename alone and either blocks a CI build or gets auto-confirmed there. The build instead refuses to run when the output directory is the project root, which is the one setting that destroys the project rather than the site, and is never what the user meant. Choosing any other directory is the user's call, and guarding it would mean second-guessing a deliberate setting, so nothing else is checked. That leaves a path outside the project working as it did in v2, which a small number of people rely on to compile into a server's public directory. It is not a case Hyde is built around, and is neither blocked nor advertised. + + 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. ### Upgrade guide diff --git a/UPGRADE.md b/UPGRADE.md index d0b720ee3da..d81d138ffe6 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -437,7 +437,7 @@ HydePHP v3 empties the entire output directory before every build. In v2 the bui Move those files into the `_static` directory, which is copied verbatim to the site root on every build, so `_static/CNAME` becomes `_site/CNAME`. -The `hyde.empty_output_directory` and `hyde.safe_output_directories` options no longer exist, and the build no longer asks for confirmation before emptying an unfamiliar output directory. Delete either entry if your `config/hyde.php` sets it. The build now fails with a configuration error if `hyde.output_directory` points outside your project, or at a directory the project needs, such as `app`, `vendor`, or one of your source directories. +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. ## Migration Checklist From 573db006bee5506e6efff11e5c55fd66b9f97f80 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 11 Aug 2026 04:35:25 +0200 Subject: [PATCH 7/7] Improve documented examples --- docs/creating-content/managing-assets.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/creating-content/managing-assets.md b/docs/creating-content/managing-assets.md index e71d09bdf10..f7663eb3191 100644 --- a/docs/creating-content/managing-assets.md +++ b/docs/creating-content/managing-assets.md @@ -18,7 +18,7 @@ To get you started quickly, all the styles are already compiled and minified int ## 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/CNAME` becomes `_site/CNAME`, and `_static/.well-known/security.txt` becomes `_site/.well-known/security.txt`. Use `_media` for normal site assets published under `/media`. +Files that need to be published directly to the site root can be placed in an optional `_static` directory. Paths are preserved, so for example `_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