From 6335e8202393d398670e334763483ba509bf2d34 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 13 Jun 2026 12:33:32 +0300 Subject: [PATCH] Detect duplicate build output paths --- src/Console/BuildCommand.php | 74 +++++++++++++++++++++++++ tests/Unit/Console/BuildCommandTest.php | 34 ++++++++++++ 2 files changed, 108 insertions(+) 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';