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