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');