From 80aa7645c1c4fcc68a5488502543e8b3bfae6718 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 13 Jun 2026 12:40:10 +0300 Subject: [PATCH] Paginate taxonomy term pages --- docs/commands.md | 2 +- docs/configuration.md | 2 +- docs/templates.md | 3 +- roadmap.md | 1 + src/Build/TaxonomyPageWriter.php | 114 +++++++++----- src/Console/BuildCommand.php | 10 +- tests/Unit/Build/TaxonomyPageWriterTest.php | 165 ++++++++++++++++++++ tests/Unit/Console/BuildCommandTest.php | 32 ++++ themes/minimal/taxonomy_term.php | 18 ++- 9 files changed, 307 insertions(+), 40 deletions(-) create mode 100644 tests/Unit/Build/TaxonomyPageWriterTest.php diff --git a/docs/commands.md b/docs/commands.md index ed8768d..e276c7d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index bdce7f4..1f2640c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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)) diff --git a/docs/templates.md b/docs/templates.md index 6f3c8ea..329000b 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -244,7 +244,8 @@ Example: | `$siteTitle` | `string` | Site title | | `$taxonomyName` | `string` | Taxonomy name | | `$term` | `string` | Term value | -| `$entries` | `list` | Entries with this term | +| `$entries` | `list` | 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`) diff --git a/roadmap.md b/roadmap.md index f36c641..2e9b5f2 100644 --- a/roadmap.md +++ b/roadmap.md @@ -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 diff --git a/src/Build/TaxonomyPageWriter.php b/src/Build/TaxonomyPageWriter.php index 55a82e3..d0fb6ec 100644 --- a/src/Build/TaxonomyPageWriter.php +++ b/src/Build/TaxonomyPageWriter.php @@ -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( @@ -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); } } @@ -93,7 +96,7 @@ private function writeIndexPage( * @param list $entries * @param array $collections */ - private function writeTermPage( + private function writeTermPages( PageTemplateRenderer $renderer, SiteConfig $siteConfig, string $taxonomyName, @@ -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); } } diff --git a/src/Console/BuildCommand.php b/src/Console/BuildCommand.php index 4e1e959..a4a666a 100644 --- a/src/Console/BuildCommand.php +++ b/src/Console/BuildCommand.php @@ -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'; + } } } } diff --git a/tests/Unit/Build/TaxonomyPageWriterTest.php b/tests/Unit/Build/TaxonomyPageWriterTest.php new file mode 100644 index 0000000..23a73b2 --- /dev/null +++ b/tests/Unit/Build/TaxonomyPageWriterTest.php @@ -0,0 +1,165 @@ +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('php — Tags — Page 2 — Test Site', $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), + ); + } +} diff --git a/tests/Unit/Console/BuildCommandTest.php b/tests/Unit/Console/BuildCommandTest.php index 6670f79..db8b689 100644 --- a/tests/Unit/Console/BuildCommandTest.php +++ b/tests/Unit/Console/BuildCommandTest.php @@ -856,6 +856,38 @@ public function testDryRunListsFilesWithoutWriting(): void assertFalse(is_dir($this->outputDir)); } + public function testDryRunListsPaginatedTaxonomyFiles(): void + { + $tempDir = sys_get_temp_dir() . '/yiipress-dry-run-taxonomy-test-' . uniqid(); + $contentDir = $tempDir . '/content'; + $outputDir = $tempDir . '/output'; + mkdir($contentDir . '/blog', 0o755, true); + $this->tempContentDirs[] = $tempDir; + + file_put_contents($contentDir . '/config.yaml', "title: Taxonomy Site\nlanguages: [en]\nentries_per_page: 2\ntaxonomies: [tags]\n"); + file_put_contents($contentDir . '/blog/_collection.yaml', "title: Blog\npermalink: /blog/:slug/\n"); + file_put_contents($contentDir . '/blog/first.md', "---\ntitle: First\ntags: [php]\n---\n\nFirst.\n"); + file_put_contents($contentDir . '/blog/second.md', "---\ntitle: Second\ntags: [php]\n---\n\nSecond.\n"); + file_put_contents($contentDir . '/blog/third.md', "---\ntitle: Third\ntags: [php]\n---\n\nThird.\n"); + + $yii = dirname(__DIR__, 3) . '/yii'; + exec( + $yii . ' build' + . ' --content-dir=' . escapeshellarg($contentDir) + . ' --output-dir=' . escapeshellarg($outputDir) + . ' --dry-run' + . ' 2>&1', + $output, + $exitCode, + ); + $outputText = implode("\n", $output); + + assertSame(0, $exitCode, "Dry run failed: $outputText"); + assertStringContainsString('/tags/php/index.html', $outputText); + assertStringContainsString('/tags/php/page/2/index.html', $outputText); + assertFalse(is_dir($outputDir)); + } + public function testIncrementalBuildSkipsUnchangedEntries(): void { $yii = dirname(__DIR__, 3) . '/yii'; diff --git a/themes/minimal/taxonomy_term.php b/themes/minimal/taxonomy_term.php index 4a1bb22..1a2498d 100644 --- a/themes/minimal/taxonomy_term.php +++ b/themes/minimal/taxonomy_term.php @@ -7,6 +7,7 @@ * @var string $taxonomyName * @var string $term * @var list $entries + * @var array{currentPage: int, totalPages: int, previousUrl: string, nextUrl: string} $pagination * @var ?Navigation $nav * @var Closure(string, array): string $partial * @var string $language @@ -19,6 +20,7 @@ * @var array> $uiCatalogs * @var YiiPress\I18n\UiText $ui * @var Closure(string, int, ?string, bool): string $h + * @var Closure(string, array): string $t */ use YiiPress\Content\Model\Navigation; @@ -26,11 +28,14 @@ $uiLanguage ??= 'en'; $taxonomyLabel = $ui->taxonomyLabel($taxonomyName); $taxonomyKey = 'taxonomy.' . strtolower($taxonomyName); +$pageTitle = $term . ' — ' . $taxonomyLabel + . ($pagination['currentPage'] > 1 ? ' — ' . $t('page_number', ['page' => $pagination['currentPage']]) : '') + . ' — ' . $siteTitle; ?> - $term . ' — ' . $taxonomyLabel . ' — ' . $siteTitle, 'rootPath' => $rootPath, 'metaTags' => $metaTags, 'search' => $search ?? false, 'searchResults' => $searchResults ?? 10, 'ui' => $ui, 'uiLanguage' => $uiLanguage, 'uiLanguages' => $uiLanguages ?? [$uiLanguage], 'uiCatalogs' => $uiCatalogs ?? [$uiLanguage => []]]) ?> + $pageTitle, 'rootPath' => $rootPath, 'metaTags' => $metaTags, 'search' => $search ?? false, 'searchResults' => $searchResults ?? 10, 'ui' => $ui, 'uiLanguage' => $uiLanguage, 'uiLanguages' => $uiLanguages ?? [$uiLanguage], 'uiCatalogs' => $uiCatalogs ?? [$uiLanguage => []]]) ?> $siteTitle, 'nav' => $nav, 'rootPath' => $rootPath, 'search' => $search ?? false, 'searchResults' => $searchResults ?? 10, 'ui' => $ui, 'uiLanguage' => $uiLanguage, 'uiLanguages' => $uiLanguages ?? [$uiLanguage]]) ?> @@ -47,6 +52,17 @@ + 1): ?> + + $nav, 'rootPath' => $rootPath, 'ui' => $ui, 'uiLanguage' => $uiLanguage]) ?>