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
4 changes: 4 additions & 0 deletions docs/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
3 changes: 0 additions & 3 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,6 @@
<MixedArrayOffset>
<code><![CDATA[$entriesByAuthor[$authorSlug]]]></code>
</MixedArrayOffset>
<MixedMethodCall>
<code><![CDATA[write]]></code>
</MixedMethodCall>
<MixedPropertyFetch>
<code><![CDATA[$entry->authors]]></code>
</MixedPropertyFetch>
Expand Down
1 change: 1 addition & 0 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
108 changes: 104 additions & 4 deletions src/Console/BuildCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
/** @var array<string, list<Entry>> $rawEntriesByCollection */
$rawEntriesByCollection = [];
$fileToPermalink = [];
$aliasRedirectTasks = [];
$aliasOutputsBySource = [];

foreach ($collections as $collectionName => $collection) {
$collectionEntries = [];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -525,12 +540,33 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$task['permalink'],
);
}
$output->writeln(' Redirects ' . ($noWrite ? 'rendered' : 'written') . ': <comment>' . count($redirectTasks) . '</comment>');
}

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') . ': <comment>' . $redirectCount . '</comment>');
}

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']]));
Expand All @@ -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 . '/';
Expand All @@ -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,
Expand Down Expand Up @@ -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']]));
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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';
Expand Down
32 changes: 32 additions & 0 deletions src/Content/Model/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ final class Entry
* @param list<string> $categories
* @param list<string> $authors
* @param array<string, mixed> $extra
* @param list<string> $aliases
*/
public function __construct(
public string $filePath,
Expand All @@ -39,6 +40,7 @@ public function __construct(
public array $inlineTags = [],
public string $translationKey = '',
public bool $showTitle = true,
public array $aliases = [],
) {}

private ?string $bodyCache = null;
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/Content/Parser/EntryParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']))
: [],
);
}

Expand Down
41 changes: 41 additions & 0 deletions tests/Unit/Console/BuildCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
14 changes: 14 additions & 0 deletions tests/Unit/Content/Parser/EntryParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down