diff --git a/docs/commands.md b/docs/commands.md index ed8768d..971c812 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -15,7 +15,7 @@ Generates static HTML content from source files. - `--content-dir`, `-c` — path to the content directory (default: `content`). Absolute or relative to project root. - `--output-dir`, `-o` — path to the output directory (default: `output`). Absolute or relative to project root. - `--workers`, `-w` — number of parallel workers or `auto` (default: `auto`). Auto mode detects available CPU capacity inside the container, caps it at `4`, and still falls back to sequential work for small task sets. -- `--no-cache` — disable build cache and incremental builds. Forces a full rebuild, clearing the output directory. By default, rendered HTML is cached and a build manifest tracks source file hashes for incremental builds. +- `--no-cache` — disable build cache and incremental builds. Forces a full rebuild, clearing the output directory only when it is empty or contains YiiPress' build marker. By default, rendered HTML is cached and a build manifest tracks source file hashes for incremental builds. - `--drafts` — include draft entries in the build. By default, entries with `draft: true` in front matter are excluded from HTML output, feeds, and sitemap. - `--future` — include future-dated entries in the build. By default, entries with a date in the future are excluded from HTML output, feeds, and sitemap. - `--dry-run` — list all files that would be generated without writing anything. The output directory is not created or modified. diff --git a/src/Console/BuildCommand.php b/src/Console/BuildCommand.php index 4e1e959..7c77ad0 100644 --- a/src/Console/BuildCommand.php +++ b/src/Console/BuildCommand.php @@ -62,6 +62,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Yiisoft\Files\FileHelper; use Yiisoft\FriendlyException\FriendlyExceptionInterface; use Yiisoft\Yii\Console\ExitCode; @@ -104,6 +105,7 @@ final class BuildCommand extends Command { private const int MAX_AUTO_WORKERS = 4; + private const string BUILD_MARKER = '.yiipress-build'; public function __construct( private readonly string $rootPath, @@ -200,17 +202,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $includeFuture = $input->getOption('future') !== false ? (bool) $input->getOption('future') : Environment::isDev(); $dryRun = (bool) $input->getOption('dry-run'); $noWrite = (bool) $input->getOption('no-write'); - $buildContext = new BuildContext( - rootPath: $rootPath, - contentDir: $contentDir, - outputDir: $outputDir, - workerCount: $workerCount, - noCache: $noCache, - includeDrafts: $includeDrafts, - includeFuture: $includeFuture, - dryRun: $dryRun, - noWrite: $noWrite, - ); $profile = new BuildProfile((bool) $input->getOption('profile')); $profile->start('prepare'); @@ -312,9 +303,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int : 'Rendering and writing output' . $this->workerMessageSuffix($workerCount, false) . '...', ); - if (!$noWrite) { - $this->prepareOutputDir($outputDir); - } } if ($manifest !== null && $allSourceFiles === []) { @@ -363,6 +351,28 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $exitCode; } + if (!$noWrite && $noCache) { + try { + $this->prepareOutputDir($outputDir); + } catch (RuntimeException $e) { + $output->writeln('' . OutputFormatter::escape($e->getMessage()) . ''); + $this->writeProfile($output, $profile); + return ExitCode::DATAERR; + } + } + + $buildContext = new BuildContext( + rootPath: $rootPath, + contentDir: $contentDir, + outputDir: $outputDir, + workerCount: $workerCount, + noCache: $noCache, + includeDrafts: $includeDrafts, + includeFuture: $includeFuture, + dryRun: $dryRun, + noWrite: $noWrite, + ); + $this->eventDispatcher?->dispatch(new BuildStartedEvent($buildContext, $siteConfig, $navigation, $collections, $authors)); /** @var array> $rawEntriesByCollection */ @@ -823,6 +833,10 @@ function (array $feedTask) use ($siteConfig, $outputDir, $authors, $noWrite): in $manifest->save(); } + if (!$noWrite) { + $this->writeBuildMarker($outputDir); + } + $this->eventDispatcher?->dispatch(new BuildFinishedEvent($buildContext, $siteConfig)); $profile->stop(); @@ -1208,14 +1222,48 @@ private function dryRun( private function prepareOutputDir(string $outputDir): void { + if (is_file($outputDir)) { + throw new RuntimeException(sprintf('Output path "%s" exists and is not a directory.', $outputDir)); + } + if (is_dir($outputDir)) { - exec('rm -rf ' . escapeshellarg($outputDir)); + if (!$this->canReplaceOutputDir($outputDir)) { + throw new RuntimeException(sprintf( + 'Refusing to clear "%s" because it is not marked as a YiiPress build output directory.', + $outputDir, + )); + } + + FileHelper::removeDirectory($outputDir); } if (!mkdir($outputDir, 0o755, true) && !is_dir($outputDir)) { throw new RuntimeException(sprintf('Directory "%s" was not created', $outputDir)); } } + private function canReplaceOutputDir(string $outputDir): bool + { + if (is_file($outputDir . '/' . self::BUILD_MARKER)) { + return true; + } + + return $this->isDirectoryEmpty($outputDir); + } + + private function isDirectoryEmpty(string $directory): bool + { + $iterator = new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS); + + return !$iterator->valid(); + } + + private function writeBuildMarker(string $outputDir): void + { + if (file_put_contents($outputDir . '/' . self::BUILD_MARKER, "generated-by: yiipress\n") === false) { + throw new RuntimeException(sprintf('Unable to write build marker in "%s".', $outputDir)); + } + } + private function resolvePath(string $path, string $rootPath): string { if (str_starts_with($path, '/')) { diff --git a/tests/Unit/Console/BuildCommandTest.php b/tests/Unit/Console/BuildCommandTest.php index 6670f79..723a403 100644 --- a/tests/Unit/Console/BuildCommandTest.php +++ b/tests/Unit/Console/BuildCommandTest.php @@ -94,6 +94,7 @@ public function testBuildGeneratesOutputFiles(): void assertSame(0, $exitCode, "Build failed: $outputText"); assertMatchesRegularExpression('/Build complete in \d+(?:\.\d+)?(?:ms|s)\. Peak memory: \d+(?:\.\d+)? MiB\./', $outputText); assertDirectoryExists($this->outputDir); + assertFileExists($this->outputDir . '/.yiipress-build'); } public function testBuildHooksAreDispatched(): void @@ -149,10 +150,12 @@ public function testBuildReportsInvalidSiteConfigWithoutTrace(): void $contentDir = $tempDir . '/content'; $outputDir = $tempDir . '/output'; mkdir($contentDir, 0o755, true); + mkdir($outputDir, 0o755, true); $this->tempContentDirs[] = $tempDir; file_put_contents($contentDir . '/config.yaml', "title: Broken Site\n"); file_put_contents($contentDir . '/index.md', "---\ntitle: Home\n---\n\nHello.\n"); + file_put_contents($outputDir . '/existing.html', 'existing output'); $yii = dirname(__DIR__, 3) . '/yii'; exec( @@ -175,6 +178,38 @@ public function testBuildReportsInvalidSiteConfigWithoutTrace(): void assertStringNotContainsString('Stack trace:', $outputText); assertStringNotContainsString('RuntimeException:', $outputText); assertStringNotContainsString('#0 ', $outputText); + assertFileExists($outputDir . '/existing.html'); + } + + public function testNoCacheBuildRefusesToClearUnmarkedNonEmptyOutputDirectory(): void + { + $tempDir = sys_get_temp_dir() . '/yiipress-build-output-safety-test-' . uniqid(); + $contentDir = $tempDir . '/content'; + $outputDir = $tempDir . '/public_html'; + mkdir($contentDir, 0o755, true); + mkdir($outputDir, 0o755, true); + $this->tempContentDirs[] = $tempDir; + + file_put_contents($contentDir . '/config.yaml', "title: Safe Site\nlanguages: [en]\n"); + file_put_contents($contentDir . '/index.md', "---\ntitle: Home\n---\n\nHello.\n"); + file_put_contents($outputDir . '/do-not-delete.html', 'important'); + + $yii = dirname(__DIR__, 3) . '/yii'; + exec( + $yii . ' build' + . ' --content-dir=' . escapeshellarg($contentDir) + . ' --output-dir=' . escapeshellarg($outputDir) + . ' --no-cache' + . ' 2>&1', + $output, + $exitCode, + ); + + $outputText = implode("\n", $output); + + assertSame(ExitCode::DATAERR, $exitCode, $outputText); + assertStringContainsString('Refusing to clear', $outputText); + assertFileExists($outputDir . '/do-not-delete.html'); } public function testBuildOutputContainsRenderedHtml(): void