Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 63 additions & 15 deletions src/Console/BuildCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -104,6 +105,7 @@
final class BuildCommand extends Command
{
private const int MAX_AUTO_WORKERS = 4;
private const string BUILD_MARKER = '.yiipress-build';

Comment on lines 107 to 109
public function __construct(
private readonly string $rootPath,
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -312,9 +303,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int
: '<info>Rendering and writing output' . $this->workerMessageSuffix($workerCount, false) . '...</info>',
);

if (!$noWrite) {
$this->prepareOutputDir($outputDir);
}
}

if ($manifest !== null && $allSourceFiles === []) {
Expand Down Expand Up @@ -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('<error>' . OutputFormatter::escape($e->getMessage()) . '</error>');
$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<string, list<Entry>> $rawEntriesByCollection */
Expand Down Expand Up @@ -823,6 +833,10 @@ function (array $feedTask) use ($siteConfig, $outputDir, $authors, $noWrite): in
$manifest->save();
}

if (!$noWrite) {
$this->writeBuildMarker($outputDir);
}
Comment on lines +836 to +838

$this->eventDispatcher?->dispatch(new BuildFinishedEvent($buildContext, $siteConfig));

$profile->stop();
Expand Down Expand Up @@ -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();
}
Comment on lines +1253 to +1258

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, '/')) {
Expand Down
35 changes: 35 additions & 0 deletions tests/Unit/Console/BuildCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down