diff --git a/src/Console/BuildCommand.php b/src/Console/BuildCommand.php
index 4e1e959..fdeffab 100644
--- a/src/Console/BuildCommand.php
+++ b/src/Console/BuildCommand.php
@@ -76,6 +76,7 @@
use function file_get_contents;
use function hash;
use function hrtime;
+use function implode;
use function is_array;
use function is_file;
use function is_readable;
@@ -428,6 +429,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$allTasks = [];
$redirectTasks = [];
$entriesByCollection = [];
+ $outputClaims = [];
foreach ($collections as $collectionName => $collection) {
$filtered = [];
foreach ($rawEntriesByCollection[$collectionName] as $entry) {
@@ -442,6 +444,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$relativePath = substr($sourcePath, strlen($contentDir) + 1);
$permalink = $fileToPermalink[$relativePath];
$filePath = $outputDir . $permalink . 'index.html';
+ $outputClaims[] = ['filePath' => $filePath, 'permalink' => $permalink, 'sourcePath' => $sourcePath];
if ($entry->redirectTo !== '') {
$redirectTasks[] = ['entry' => $entry, 'filePath' => $filePath, 'permalink' => $permalink, 'sourcePath' => $sourcePath];
@@ -459,6 +462,32 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$entriesByCollection[$collectionName] = EntrySorter::sort($filtered, $collection);
}
+ foreach ($standalonePages as $page) {
+ if (!$includeDrafts && $page->draft) {
+ continue;
+ }
+ if (!$includeFuture && $page->date !== null && $page->date > $now) {
+ continue;
+ }
+
+ $basePermalink = $page->permalink !== '' ? $page->permalink : '/' . $page->slug . '/';
+ $permalink = PermalinkResolver::applyLanguagePrefix($basePermalink, $page->language, $siteConfig->i18n);
+ $outputClaims[] = [
+ 'filePath' => $outputDir . $permalink . 'index.html',
+ 'permalink' => $permalink,
+ 'sourcePath' => $page->filePath,
+ ];
+ }
+
+ $duplicateOutputClaims = $this->duplicateOutputClaims($outputClaims, $contentDir);
+ foreach ($duplicateOutputClaims as $message) {
+ $output->writeln(' ' . OutputFormatter::escape($message) . '');
+ }
+ if ($duplicateOutputClaims !== []) {
+ $this->writeProfile($output, $profile);
+ return ExitCode::DATAERR;
+ }
+
foreach ($allTasks as $index => $task) {
$collection = $collections[$task['entry']->collection] ?? null;
if ($collection !== null) {
@@ -1350,6 +1379,51 @@ private function withNavigationPager(array $task, Collection $collection, SiteCo
return $task;
}
+ /**
+ * @param list $claims
+ * @return list
+ */
+ private function duplicateOutputClaims(array $claims, string $contentDir): array
+ {
+ $claimsByOutput = [];
+ foreach ($claims as $claim) {
+ $claimsByOutput[$claim['filePath']][] = $claim;
+ }
+
+ $messages = [];
+ foreach ($claimsByOutput as $filePath => $outputClaims) {
+ if (count($outputClaims) < 2) {
+ continue;
+ }
+
+ $sources = [];
+ foreach ($outputClaims as $claim) {
+ $sources[] = sprintf(
+ '%s (%s)',
+ $this->contentRelativePath($claim['sourcePath'], $contentDir),
+ $claim['permalink'],
+ );
+ }
+
+ $messages[] = sprintf(
+ 'Duplicate output path "%s" is claimed by %s.',
+ $filePath,
+ implode(' and ', $sources),
+ );
+ }
+
+ return $messages;
+ }
+
+ private function contentRelativePath(string $sourcePath, string $contentDir): string
+ {
+ $prefix = $contentDir . '/';
+ if (str_starts_with($sourcePath, $prefix)) {
+ return substr($sourcePath, strlen($prefix));
+ }
+
+ return $sourcePath;
+ }
/**
* @return list
diff --git a/tests/Unit/Console/BuildCommandTest.php b/tests/Unit/Console/BuildCommandTest.php
index 6670f79..a649de7 100644
--- a/tests/Unit/Console/BuildCommandTest.php
+++ b/tests/Unit/Console/BuildCommandTest.php
@@ -177,6 +177,40 @@ public function testBuildReportsInvalidSiteConfigWithoutTrace(): void
assertStringNotContainsString('#0 ', $outputText);
}
+ public function testBuildFailsOnDuplicateEntryPermalinks(): void
+ {
+ $tempDir = sys_get_temp_dir() . '/yiipress-build-duplicate-permalink-test-' . uniqid();
+ $contentDir = $tempDir . '/content';
+ $outputDir = $tempDir . '/output';
+ mkdir($contentDir . '/blog', 0o755, true);
+ $this->tempContentDirs[] = $tempDir;
+
+ file_put_contents($contentDir . '/config.yaml', "title: Duplicate Site\nlanguages: [en]\n");
+ file_put_contents($contentDir . '/blog/_collection.yaml', "title: Blog\npermalink: /blog/:slug/\n");
+ file_put_contents($contentDir . '/blog/first.md', "---\ntitle: First\npermalink: /blog/same/\n---\n\nFirst.\n");
+ file_put_contents($contentDir . '/blog/second.md', "---\ntitle: Second\npermalink: /blog/same/\n---\n\nSecond.\n");
+
+ $yii = dirname(__DIR__, 3) . '/yii';
+ exec(
+ $yii . ' build'
+ . ' --content-dir=' . escapeshellarg($contentDir)
+ . ' --output-dir=' . escapeshellarg($outputDir)
+ . ' --workers=2'
+ . ' --no-cache'
+ . ' 2>&1',
+ $output,
+ $exitCode,
+ );
+ $outputText = implode("\n", $output);
+
+ assertSame(ExitCode::DATAERR, $exitCode, $outputText);
+ assertStringContainsString('Duplicate output path', $outputText);
+ assertStringContainsString('/blog/same/index.html', $outputText);
+ assertStringContainsString('blog/first.md (/blog/same/)', $outputText);
+ assertStringContainsString('blog/second.md (/blog/same/)', $outputText);
+ assertFalse(is_file($outputDir . '/blog/same/index.html'));
+ }
+
public function testBuildOutputContainsRenderedHtml(): void
{
$yii = dirname(__DIR__, 3) . '/yii';