diff --git a/docs/content.md b/docs/content.md index 89320ae..0f7f6f5 100644 --- a/docs/content.md +++ b/docs/content.md @@ -95,6 +95,9 @@ authors: image: /blog/assets/hero.jpg summary: A brief introduction to YiiPress. permalink: /custom/path/ +aliases: + - /old-path/ + - /legacy/path/ layout: post theme: custom weight: 10 @@ -115,6 +118,7 @@ extra: - **image** — featured image URL (absolute, or root-relative path resolved against `base_url`); used as `og:image` for social sharing. Falls back to the site-level `image` in `config.yaml` - **summary** — manual excerpt; if omitted, auto-generated from content - **permalink** — per-entry URL override; takes precedence over collection pattern +- **aliases** — old URLs for this entry. YiiPress writes redirect pages from each alias to the entry permalink. Aliases are site-root paths and are not added to feeds, listings, or sitemap - **layout** — template layout name (default: collection-specific or `entry`) - **theme** — theme name for this entry; overrides the site-level default (see [Templates](templates.md)) - **weight** — integer for custom sorting in non-blog collections (lower = first) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index aecee89..5f1ed81 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -145,9 +145,6 @@ - - - authors]]> diff --git a/roadmap.md b/roadmap.md index f36c641..353b20e 100644 --- a/roadmap.md +++ b/roadmap.md @@ -84,6 +84,7 @@ - [x] Canonical URL support - [x] Configurable `robots.txt` generation - [x] Redirect support (e.g., when changing permalinks, output redirect HTML or config) +- [x] Entry aliases in front matter for old URL redirects - [x] Root-relative redirects resolve against deployment paths from `base_url` - [x] 404 page in static build output for static hosting providers (Netlify, GitHub Pages, etc.) - [x] Deployment helpers/docs for common static hosts (GitHub Pages, Netlify, Vercel, Cloudflare Pages) diff --git a/src/Console/BuildCommand.php b/src/Console/BuildCommand.php index 4e1e959..500f65c 100644 --- a/src/Console/BuildCommand.php +++ b/src/Console/BuildCommand.php @@ -368,6 +368,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** @var array> $rawEntriesByCollection */ $rawEntriesByCollection = []; $fileToPermalink = []; + $aliasRedirectTasks = []; + $aliasOutputsBySource = []; foreach ($collections as $collectionName => $collection) { $collectionEntries = []; @@ -449,6 +451,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $filtered[] = $entry; + foreach ($entry->aliases as $alias) { + $aliasPermalink = $this->normalizeAliasPermalink($alias); + $aliasFilePath = $this->aliasFilePath($outputDir, $aliasPermalink); + $aliasRedirectTasks[] = [ + 'entry' => $entry->withRedirectTo($permalink), + 'filePath' => $aliasFilePath, + 'permalink' => $aliasPermalink, + 'sourcePath' => $sourcePath, + ]; + $aliasOutputsBySource[$sourcePath][] = $aliasFilePath; + } $allTasks[] = [ 'entry' => $entry, 'filePath' => $filePath, @@ -511,6 +524,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); $profile->switchTo('write redirects'); + /** @var RedirectPageWriter|null $redirectWriter */ + $redirectWriter = null; if ($redirectTasks !== []) { $redirectWriter = new RedirectPageWriter(); foreach ($redirectTasks as $task) { @@ -525,12 +540,33 @@ protected function execute(InputInterface $input, OutputInterface $output): int $task['permalink'], ); } - $output->writeln(' Redirects ' . ($noWrite ? 'rendered' : 'written') . ': ' . count($redirectTasks) . ''); + } + + if ($aliasRedirectTasks !== []) { + $redirectWriter ??= new RedirectPageWriter(); + foreach ($aliasRedirectTasks as $task) { + $language = $task['entry']->language !== '' ? $task['entry']->language : $siteConfig->defaultLanguage; + $redirectWriter->write( + $task['entry'], + $task['filePath'], + $language, + UiText::forTheme($siteConfig->defaultLanguage, $this->templateResolver, $siteConfig->theme, $siteConfig->defaultLanguage), + $noWrite, + $siteConfig, + $task['permalink'], + ); + } + } + + $redirectCount = count($redirectTasks) + count($aliasRedirectTasks); + if ($redirectCount > 0) { + $output->writeln(' Redirects ' . ($noWrite ? 'rendered' : 'written') . ': ' . $redirectCount . ''); } if ($manifest !== null) { foreach ($allTasks as $task) { - $this->removeStaleOutputs($manifest->replace($task['sourcePath'], [$task['filePath']])); + $outputs = [$task['filePath'], ...($aliasOutputsBySource[$task['sourcePath']] ?? [])]; + $this->removeStaleOutputs($manifest->replace($task['sourcePath'], $outputs)); } foreach ($redirectTasks as $task) { $this->removeStaleOutputs($manifest->replace($task['sourcePath'], [$task['filePath']])); @@ -554,6 +590,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $profile->switchTo('write standalone pages'); $standaloneTasks = []; $standaloneRedirectTasks = []; + $standaloneAliasRedirectTasks = []; foreach ($standalonePages as $page) { $sourcePath = $page->filePath; $basePermalink = $page->permalink !== '' ? $page->permalink : '/' . $page->slug . '/'; @@ -568,6 +605,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($page->redirectTo !== '') { $standaloneRedirectTasks[] = ['entry' => $page, 'filePath' => $filePath, 'permalink' => $permalink, 'sourcePath' => $sourcePath]; } else { + foreach ($page->aliases as $alias) { + $aliasPermalink = $this->normalizeAliasPermalink($alias); + $aliasFilePath = $this->aliasFilePath($outputDir, $aliasPermalink); + $standaloneAliasRedirectTasks[] = [ + 'entry' => $page->withRedirectTo($permalink), + 'filePath' => $aliasFilePath, + 'permalink' => $aliasPermalink, + 'sourcePath' => $sourcePath, + ]; + $aliasOutputsBySource[$sourcePath][] = $aliasFilePath; + } $standaloneTask = [ 'entry' => $page, 'filePath' => $filePath, @@ -607,11 +655,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int $task['permalink'], ); } - $standalonePagesWritten += count($standaloneRedirectTasks); + foreach ($standaloneAliasRedirectTasks as $task) { + $language = $task['entry']->language !== '' ? $task['entry']->language : $siteConfig->defaultLanguage; + $redirectWriter->write( + $task['entry'], + $task['filePath'], + $language, + UiText::forTheme($siteConfig->defaultLanguage, $this->templateResolver, $siteConfig->theme, $siteConfig->defaultLanguage), + $noWrite, + $siteConfig, + $task['permalink'], + ); + } + $standalonePagesWritten += count($standaloneRedirectTasks) + count($standaloneAliasRedirectTasks); if ($manifest !== null) { foreach ($standaloneTasks as $task) { - $this->removeStaleOutputs($manifest->replace($task['sourcePath'], [$task['filePath']])); + $outputs = [$task['filePath'], ...($aliasOutputsBySource[$task['sourcePath']] ?? [])]; + $this->removeStaleOutputs($manifest->replace($task['sourcePath'], $outputs)); } foreach ($standaloneRedirectTasks as $task) { $this->removeStaleOutputs($manifest->replace($task['sourcePath'], [$task['filePath']])); @@ -1077,6 +1138,9 @@ private function dryRun( $permalink = PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n); $files[] = $outputDir . $permalink . 'index.html'; if ($entry->redirectTo === '') { + foreach ($entry->aliases as $alias) { + $files[] = $this->aliasFilePath($outputDir, $this->normalizeAliasPermalink($alias)); + } $entries[] = $entry; } } @@ -1131,6 +1195,11 @@ private function dryRun( } $permalink = $page->permalink !== '' ? $page->permalink : '/' . $page->slug . '/'; $files[] = $outputDir . $permalink . 'index.html'; + if ($page->redirectTo === '') { + foreach ($page->aliases as $alias) { + $files[] = $this->aliasFilePath($outputDir, $this->normalizeAliasPermalink($alias)); + } + } } $contentAssetCopier = new ContentAssetCopier(); @@ -1237,6 +1306,37 @@ private function removeStaleOutputs(array $outputFiles): void } } + private function normalizeAliasPermalink(string $alias): string + { + $alias = trim($alias); + if ($alias === '') { + throw new RuntimeException('Entry aliases must not be empty.'); + } + + if (str_contains($alias, '://') || str_starts_with($alias, '//') || str_contains($alias, '?') || str_contains($alias, '#')) { + throw new RuntimeException(sprintf('Entry alias "%s" must be a site-root path.', $alias)); + } + + $alias = '/' . trim($alias, '/') . '/'; + $segments = explode('/', trim($alias, '/')); + foreach ($segments as $segment) { + if ($segment === '.' || $segment === '..') { + throw new RuntimeException(sprintf('Entry alias "%s" must not contain "." or ".." path segments.', $alias)); + } + } + + return $alias === '//' ? '/' : preg_replace('#/+#', '/', $alias) ?? $alias; + } + + private function aliasFilePath(string $outputDir, string $aliasPermalink): string + { + if ($aliasPermalink === '/') { + return $outputDir . '/index.html'; + } + + return $outputDir . rtrim($aliasPermalink, '/') . '/index.html'; + } + private function assetFingerprintEnabled(string $contentDir): bool { $configPath = $contentDir . '/config.yaml'; diff --git a/src/Content/Model/Entry.php b/src/Content/Model/Entry.php index 302721d..12fa02e 100644 --- a/src/Content/Model/Entry.php +++ b/src/Content/Model/Entry.php @@ -14,6 +14,7 @@ final class Entry * @param list $categories * @param list $authors * @param array $extra + * @param list $aliases */ public function __construct( public string $filePath, @@ -39,6 +40,7 @@ public function __construct( public array $inlineTags = [], public string $translationKey = '', public bool $showTitle = true, + public array $aliases = [], ) {} private ?string $bodyCache = null; @@ -49,6 +51,36 @@ public function sourceFilePath(): string return $this->filePath; } + public function withRedirectTo(string $redirectTo): self + { + return new self( + filePath: $this->filePath, + collection: $this->collection, + slug: $this->slug, + title: $this->title, + date: $this->date, + draft: $this->draft, + tags: $this->tags, + categories: $this->categories, + authors: $this->authors, + summary: $this->summary, + permalink: $this->permalink, + layout: $this->layout, + theme: $this->theme, + weight: $this->weight, + language: $this->language, + redirectTo: $redirectTo, + extra: $this->extra, + bodyOffset: $this->bodyOffset, + bodyLength: $this->bodyLength, + image: $this->image, + inlineTags: $this->inlineTags, + translationKey: $this->translationKey, + showTitle: $this->showTitle, + aliases: $this->aliases, + ); + } + private const int SUMMARY_LENGTH = 300; public function summary(): string diff --git a/src/Content/Parser/EntryParser.php b/src/Content/Parser/EntryParser.php index cb8ad4b..7e0c68a 100644 --- a/src/Content/Parser/EntryParser.php +++ b/src/Content/Parser/EntryParser.php @@ -94,6 +94,9 @@ public function parse(string $filePath, string $collectionName): Entry image: (string) ($fields['image'] ?? ''), translationKey: (string) ($fields['translation_key'] ?? ''), showTitle: (bool) ($fields['showTitle'] ?? true), + aliases: isset($fields['aliases']) && is_array($fields['aliases']) + ? array_values(array_map(strval(...), $fields['aliases'])) + : [], ); } diff --git a/tests/Unit/Console/BuildCommandTest.php b/tests/Unit/Console/BuildCommandTest.php index 6670f79..08eaad6 100644 --- a/tests/Unit/Console/BuildCommandTest.php +++ b/tests/Unit/Console/BuildCommandTest.php @@ -477,6 +477,47 @@ public function testBuildIncludesDraftsWithFlag(): void assertStringNotContainsString('No Date Post', $atom); } + public function testBuildGeneratesRedirectsForEntryAliases(): void + { + $tempDir = sys_get_temp_dir() . '/yiipress-build-alias-test-' . uniqid(); + $contentDir = $tempDir . '/content'; + $outputDir = $tempDir . '/output'; + mkdir($contentDir . '/blog', 0o755, true); + $this->tempContentDirs[] = $tempDir; + + file_put_contents($contentDir . '/config.yaml', "title: Alias Site\nbase_url: https://example.com\nlanguages: [en]\n"); + file_put_contents($contentDir . '/blog/_collection.yaml', "title: Blog\npermalink: /blog/:slug/\n"); + file_put_contents($contentDir . '/blog/post.md', "---\ntitle: Post\naliases:\n - /old-post/\n - legacy/post\n---\n\nBody.\n"); + + $yii = dirname(__DIR__, 3) . '/yii'; + exec( + $yii . ' build' + . ' --content-dir=' . escapeshellarg($contentDir) + . ' --output-dir=' . escapeshellarg($outputDir) + . ' --no-cache' + . ' 2>&1', + $output, + $exitCode, + ); + + assertSame(0, $exitCode, implode("\n", $output)); + + $oldPost = file_get_contents($outputDir . '/old-post/index.html'); + assertNotFalse($oldPost); + assertStringContainsString('http-equiv="refresh"', $oldPost); + assertStringContainsString('href="/blog/post/"', $oldPost); + + $legacy = file_get_contents($outputDir . '/legacy/post/index.html'); + assertNotFalse($legacy); + assertStringContainsString('href="/blog/post/"', $legacy); + + $sitemap = file_get_contents($outputDir . '/sitemap.xml'); + assertNotFalse($sitemap); + assertStringContainsString('/blog/post/', $sitemap); + assertStringNotContainsString('/old-post/', $sitemap); + assertStringNotContainsString('/legacy/post/', $sitemap); + } + public function testBuildExcludesFutureDatedEntriesByDefault(): void { $yii = dirname(__DIR__, 3) . '/yii'; diff --git a/tests/Unit/Content/Parser/EntryParserTest.php b/tests/Unit/Content/Parser/EntryParserTest.php index 1cc698d..b549599 100644 --- a/tests/Unit/Content/Parser/EntryParserTest.php +++ b/tests/Unit/Content/Parser/EntryParserTest.php @@ -60,6 +60,20 @@ public function testParseEntryWithFrontMatterDateOverridesFilename(): void assertSame(['custom_field' => 'value'], $entry->extra); } + public function testParseEntryAliases(): void + { + $file = tempnam(sys_get_temp_dir(), 'yiipress-entry-aliases-') . '.md'; + file_put_contents($file, "---\ntitle: Aliased\naliases:\n - /old-url/\n - legacy-url\n---\n\nBody.\n"); + + try { + $entry = $this->parser->parse($file, 'blog'); + + assertSame(['/old-url/', 'legacy-url'], $entry->aliases); + } finally { + unlink($file); + } + } + public function testEntryBodyIsLoadedLazily(): void { $entry = $this->parser->parse($this->dataDir . '/blog/2024-03-15-test-post.md', 'blog');