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
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The command:
7. Generates Atom (`feed.xml`) and RSS 2.0 (`rss.xml`) feeds for each collection with `feed: true`, capped by collection `feed_limit` (`20` by default, `0` for unlimited).
8. Generates paginated collection listing pages (e.g., `/blog/`, `/blog/page/2/`) for collections with `listing: true`.
9. Generates `sitemap.xml` containing all entry URLs, standalone page URLs, collection listing URLs, and the home page.
10. Generates taxonomy pages for each taxonomy defined in `config.yaml` (e.g., `/tags/`, `/tags/php/`, `/categories/`).
10. Generates taxonomy pages for each taxonomy defined in `config.yaml` (e.g., `/tags/`, `/tags/php/`, `/tags/php/page/2/`, `/categories/`).

With `--workers=N` (N > 1), entry rendering and writing is parallelized across N forked processes. With `--workers=auto`, YiiPress uses up to the detected worker count and lets page writers clamp back to sequential mode for smaller workloads. Feeds are generated after entry writing and can be split per collection across workers. Sitemap generation remains serial.

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ editor: code
- **default_author** — author slug (referencing a file in `content/authors/`), used when entries have no explicit `authors` field
- **author_pages** — set to `true` to generate `/authors/` and `/authors/:slug/` pages and link known entry authors to them (default: `false`)
- **date_format** — PHP date format string for displaying dates in templates (e.g., `Y.m.d` for "2026.03.23", `F j, Y` for "March 23, 2026"). See [PHP date format](https://www.php.net/manual/en/datetime.format.php) for all available format characters
- **entries_per_page** — default pagination size (overridden by collection `_collection.yaml`)
- **entries_per_page** — default pagination size for taxonomy term pages and collection listings (overridden by collection `_collection.yaml` for collection listings)
- **permalink** — default permalink pattern (overridden by collection or entry)
- **taxonomies** — list of enabled taxonomy types
- **theme** — default theme name for the site (see [Templates](templates.md))
Expand Down
3 changes: 2 additions & 1 deletion docs/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,8 @@ Example:
| `$siteTitle` | `string` | Site title |
| `$taxonomyName` | `string` | Taxonomy name |
| `$term` | `string` | Term value |
| `$entries` | `list<array{title: string, url: string, date: string}>` | Entries with this term |
| `$entries` | `list<array{title: string, url: string, date: string}>` | Entries on the current term page |
| `$pagination` | `array{currentPage: int, totalPages: int, previousUrl: string, nextUrl: string}` | Pagination data |
| `$nav` | `?Navigation` | Navigation object or `null` |

### Author page template (`author.php`)
Expand Down
1 change: 1 addition & 0 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- [x] Date-based archive pages (yearly, monthly)
- [x] Cross-references / internal linking helpers (shorthand syntax to link between entries, prevents broken links on permalink changes)
- [x] Markdown extensions configuration (enable/disable footnotes, definition lists, strikethrough, tables, etc.)
- [x] Taxonomy term pagination

## Priority 2: Documentation

Expand Down
114 changes: 79 additions & 35 deletions src/Build/TaxonomyPageWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
use YiiPress\Content\PermalinkResolver;
use RuntimeException;

use function array_chunk;
use function count;
use function rtrim;

final readonly class TaxonomyPageWriter
{
public function __construct(
Expand Down Expand Up @@ -44,8 +48,7 @@ public function write(
$pageCount++;

foreach ($terms as $term => $entries) {
$this->writeTermPage($renderer, $siteConfig, $taxonomyName, (string) $term, $entries, $collections, $outputDir, $navigation, $noWrite);
$pageCount++;
$pageCount += $this->writeTermPages($renderer, $siteConfig, $taxonomyName, (string) $term, $entries, $collections, $outputDir, $navigation, $noWrite);
}
}

Expand Down Expand Up @@ -93,7 +96,7 @@ private function writeIndexPage(
* @param list<Entry> $entries
* @param array<string, Collection> $collections
*/
private function writeTermPage(
private function writeTermPages(
PageTemplateRenderer $renderer,
SiteConfig $siteConfig,
string $taxonomyName,
Expand All @@ -103,47 +106,88 @@ private function writeTermPage(
string $outputDir,
?Navigation $navigation,
bool $noWrite,
): void {
$rootPath = UrlResolver::rootPath('/' . $taxonomyName . '/' . $term . '/');
): int {
$perPage = $siteConfig->entriesPerPage;
if ($perPage <= 0) {
$perPage = count($entries) ?: 1;
}

$pages = array_chunk($entries, $perPage);
$totalPages = count($pages);
$pageCount = 0;
$uiViewData = UiViewData::forSite($siteConfig, $this->templateResolver, $siteConfig->theme);
$taxonomyLabel = $uiViewData->ui->taxonomyLabel($taxonomyName);

$entryData = [];
foreach ($entries as $entry) {
$collection = $collections[$entry->collection] ?? null;
$url = $collection !== null
? UrlResolver::sitePath(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath)
: '#';

$entryData[] = [
'title' => $entry->title,
'url' => $url,
'date' => $entry->date?->format($siteConfig->dateFormat) ?? '',
foreach ($pages as $pageIndex => $pageEntries) {
$pageNumber = $pageIndex + 1;
$permalink = $this->termPagePermalink($taxonomyName, $term, $pageNumber);
$rootPath = UrlResolver::rootPath($permalink);

$entryData = [];
foreach ($pageEntries as $entry) {
$collection = $collections[$entry->collection] ?? null;
$url = $collection !== null
? UrlResolver::sitePath(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath)
: '#';

$entryData[] = [
'title' => $entry->title,
'url' => $url,
'date' => $entry->date?->format($siteConfig->dateFormat) ?? '',
];
}

$pagination = [
'currentPage' => $pageNumber,
'totalPages' => $totalPages,
'previousUrl' => $this->resolveTermPageUrl($taxonomyName, $term, $pageNumber - 1, $totalPages, $rootPath),
'nextUrl' => $this->resolveTermPageUrl($taxonomyName, $term, $pageNumber + 1, $totalPages, $rootPath),
];

$html = $renderer->render('taxonomy_term', [
'siteTitle' => $siteConfig->title,
'taxonomyName' => $taxonomyName,
'term' => $term,
'entries' => $entryData,
'pagination' => $pagination,
'nav' => $navigation,
'rootPath' => $rootPath,
'language' => $siteConfig->defaultLanguage,
'metaTags' => MetaTagsBuilder::forPage($siteConfig, $term . ' — ' . $taxonomyLabel, $siteConfig->description, $permalink),
'search' => $siteConfig->search !== null,
'searchResults' => $siteConfig->search?->results ?? 10,
] + $uiViewData->toArray(), $rootPath);

if (!$noWrite) {
$dir = $outputDir . rtrim($permalink, '/');
if (!is_dir($dir) && !mkdir($dir, 0o755, true) && !is_dir($dir)) {
throw new RuntimeException(sprintf('Directory "%s" was not created', $dir));
}

file_put_contents($dir . '/index.html', $html);
}

$pageCount++;
}

$entries = $entryData;
return $pageCount;
}

$html = $renderer->render('taxonomy_term', [
'siteTitle' => $siteConfig->title,
'taxonomyName' => $taxonomyName,
'term' => $term,
'entries' => $entries,
'nav' => $navigation,
'rootPath' => $rootPath,
'language' => $siteConfig->defaultLanguage,
'metaTags' => MetaTagsBuilder::forPage($siteConfig, $term . ' — ' . $taxonomyLabel, $siteConfig->description, '/' . $taxonomyName . '/' . $term . '/'),
'search' => $siteConfig->search !== null,
'searchResults' => $siteConfig->search?->results ?? 10,
] + $uiViewData->toArray(), $rootPath);
private function termPagePermalink(string $taxonomyName, string $term, int $pageNumber): string
{
if ($pageNumber === 1) {
return '/' . $taxonomyName . '/' . $term . '/';
}

if (!$noWrite) {
$dir = $outputDir . '/' . $taxonomyName . '/' . $term;
if (!is_dir($dir) && !mkdir($dir, 0o755, true) && !is_dir($dir)) {
throw new RuntimeException(sprintf('Directory "%s" was not created', $dir));
}
return '/' . $taxonomyName . '/' . $term . '/page/' . $pageNumber . '/';
}

file_put_contents($dir . '/index.html', $html);
private function resolveTermPageUrl(string $taxonomyName, string $term, int $pageNumber, int $totalPages, string $rootPath): string
{
if ($pageNumber < 1 || $pageNumber > $totalPages) {
return '';
}

return UrlResolver::sitePath($this->termPagePermalink($taxonomyName, $term, $pageNumber), $rootPath);
}
}
10 changes: 9 additions & 1 deletion src/Console/BuildCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -1182,8 +1182,16 @@ private function dryRun(
continue;
}
$files[] = $outputDir . '/' . $taxonomyName . '/index.html';
foreach (array_keys($terms) as $term) {
foreach ($terms as $term => $termEntries) {
$files[] = $outputDir . '/' . $taxonomyName . '/' . $term . '/index.html';
$perPage = $siteConfig->entriesPerPage;
if ($perPage <= 0) {
$perPage = count($termEntries) ?: 1;
}
$totalPages = $termEntries !== [] ? (int) ceil(count($termEntries) / $perPage) : 1;
for ($p = 2; $p <= $totalPages; $p++) {
$files[] = $outputDir . '/' . $taxonomyName . '/' . $term . '/page/' . $p . '/index.html';
}
}
}
}
Expand Down
165 changes: 165 additions & 0 deletions tests/Unit/Build/TaxonomyPageWriterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php

declare(strict_types=1);

namespace YiiPress\Tests\Unit\Build;

use DateTimeImmutable;
use FilesystemIterator;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use YiiPress\Build\TaxonomyPageWriter;
use YiiPress\Build\TemplateResolver;
use YiiPress\Build\Theme;
use YiiPress\Build\ThemeRegistry;
use YiiPress\Content\Model\Collection;
use YiiPress\Content\Model\Entry;
use YiiPress\Content\Model\SiteConfig;

use function PHPUnit\Framework\assertFileExists;
use function PHPUnit\Framework\assertSame;
use function PHPUnit\Framework\assertStringContainsString;
use function PHPUnit\Framework\assertStringNotContainsString;

final class TaxonomyPageWriterTest extends TestCase
{
private SiteConfig $siteConfig;
private string $outputDir;
private string $tempFile;

protected function setUp(): void
{
$this->siteConfig = new SiteConfig(
title: 'Test Site',
description: 'A test site',
baseUrl: 'https://test.example.com',
defaultLanguage: 'en',
charset: 'UTF-8',
defaultAuthor: 'john-doe',
dateFormat: 'F j, Y',
entriesPerPage: 2,
permalink: '/:collection/:slug/',
taxonomies: ['tags'],
params: [],
);

$this->outputDir = sys_get_temp_dir() . '/yiipress-taxonomy-page-test-' . uniqid();
mkdir($this->outputDir, 0o755, true);

$this->tempFile = sys_get_temp_dir() . '/yiipress-taxonomy-page-body-' . uniqid() . '.md';
file_put_contents($this->tempFile, "Body.\n");
}

protected function tearDown(): void
{
if (is_file($this->tempFile)) {
unlink($this->tempFile);
}

if (!is_dir($this->outputDir)) {
return;
}

$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->outputDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iterator as $item) {
/** @var SplFileInfo $item */
if ($item->isDir()) {
rmdir($item->getPathname());
} else {
unlink($item->getPathname());
}
}
rmdir($this->outputDir);
}

public function testWritesPaginatedTaxonomyTermPages(): void
{
$entries = [
$this->createEntry('post-1', 'Post 1', '2024-03-01'),
$this->createEntry('post-2', 'Post 2', '2024-03-02'),
$this->createEntry('post-3', 'Post 3', '2024-03-03'),
];
$collections = ['blog' => $this->createCollection()];

$writer = new TaxonomyPageWriter($this->createTemplateResolver());
$pageCount = $writer->write(
$this->siteConfig,
['tags' => ['php' => $entries]],
$collections,
$this->outputDir,
);

assertSame(3, $pageCount);
assertFileExists($this->outputDir . '/tags/index.html');
assertFileExists($this->outputDir . '/tags/php/index.html');
assertFileExists($this->outputDir . '/tags/php/page/2/index.html');

$page1 = file_get_contents($this->outputDir . '/tags/php/index.html');
assertStringContainsString('Post 1', $page1);
assertStringContainsString('Post 2', $page1);
assertStringNotContainsString('Post 3', $page1);
assertStringContainsString('Page 1 of 2', $page1);
assertStringContainsString('rel="next"', $page1);
assertStringNotContainsString('rel="prev"', $page1);

$page2 = file_get_contents($this->outputDir . '/tags/php/page/2/index.html');
assertStringContainsString('<title>php — Tags — Page 2 — Test Site</title>', $page2);
assertStringContainsString('Post 3', $page2);
assertStringContainsString('Page 2 of 2', $page2);
assertStringContainsString('rel="prev"', $page2);
assertStringNotContainsString('rel="next"', $page2);
}

private function createTemplateResolver(): TemplateResolver
{
$registry = new ThemeRegistry();
$registry->register(new Theme('minimal', dirname(__DIR__, 3) . '/themes/minimal'));

return new TemplateResolver($registry);
}

private function createCollection(): Collection
{
return new Collection(
name: 'blog',
title: 'Blog',
description: 'Latest posts',
permalink: '/blog/:slug/',
sortBy: 'date',
sortOrder: 'desc',
entriesPerPage: 10,
feed: true,
listing: true,
);
}

private function createEntry(string $slug, string $title, string $date): Entry
{
return new Entry(
filePath: $this->tempFile,
collection: 'blog',
slug: $slug,
title: $title,
date: new DateTimeImmutable($date),
draft: false,
tags: ['php'],
categories: [],
authors: [],
summary: '',
permalink: '',
layout: '',
theme: '',
weight: 0,
language: '',
redirectTo: '',
extra: [],
bodyOffset: 0,
bodyLength: (int) filesize($this->tempFile),
);
}
}
Loading