From 593f838da54b0b0e7a463a16ec2297e53c884c5d Mon Sep 17 00:00:00 2001 From: Nico Devs Date: Fri, 24 Apr 2026 15:21:24 -0300 Subject: [PATCH 1/3] Add dynamic collection pagination via paginateCollection() --- src/Collection/CollectionPaginator.php | 24 +-- src/File/OutputFile.php | 6 +- src/Handlers/PaginatedPageHandler.php | 66 ++++++-- src/Jigsaw.php | 12 ++ src/SiteBuilder.php | 46 +++++- src/View/ViewRenderer.php | 5 + tests/DynamicCollectionPaginationTest.php | 188 ++++++++++++++++++++++ 7 files changed, 314 insertions(+), 33 deletions(-) create mode 100644 tests/DynamicCollectionPaginationTest.php diff --git a/src/Collection/CollectionPaginator.php b/src/Collection/CollectionPaginator.php index f39759fc..bf7eafdd 100644 --- a/src/Collection/CollectionPaginator.php +++ b/src/Collection/CollectionPaginator.php @@ -15,27 +15,27 @@ public function __construct($outputPathResolver) $this->outputPathResolver = $outputPathResolver; } - public function paginate($file, $items, $perPage, $prefix) + public function paginate(string $relativePath, string $filename, $items, $perPage, string $prefix = '') { $chunked = collect($items)->chunk($perPage); $totalPages = $chunked->count(); $this->prefix = $prefix; - $numberedPageLinks = $chunked->map(function ($_, $i) use ($file) { + $numberedPageLinks = $chunked->map(function ($_, $i) use ($relativePath, $filename) { $page = $i + 1; - return ['number' => $page, 'path' => $this->getPageLink($file, $page)]; + return ['number' => $page, 'path' => $this->getPageLink($relativePath, $filename, $page)]; })->pluck('path', 'number'); - return $chunked->map(function ($items, $i) use ($file, $totalPages, $numberedPageLinks) { + return $chunked->map(function ($items, $i) use ($relativePath, $filename, $totalPages, $numberedPageLinks) { $currentPage = $i + 1; return new IterableObject([ 'items' => $items, - 'previous' => $currentPage > 1 ? $this->getPageLink($file, $currentPage - 1) : null, - 'current' => $this->getPageLink($file, $currentPage), - 'next' => $currentPage < $totalPages ? $this->getPageLink($file, $currentPage + 1) : null, - 'first' => $this->getPageLink($file, 1), - 'last' => $this->getPageLink($file, $totalPages), + 'previous' => $currentPage > 1 ? $this->getPageLink($relativePath, $filename, $currentPage - 1) : null, + 'current' => $this->getPageLink($relativePath, $filename, $currentPage), + 'next' => $currentPage < $totalPages ? $this->getPageLink($relativePath, $filename, $currentPage + 1) : null, + 'first' => $this->getPageLink($relativePath, $filename, 1), + 'last' => $this->getPageLink($relativePath, $filename, $totalPages), 'currentPage' => $currentPage, 'totalPages' => $totalPages, 'pages' => $numberedPageLinks, @@ -43,11 +43,11 @@ public function paginate($file, $items, $perPage, $prefix) }); } - private function getPageLink($file, $pageNumber) + private function getPageLink(string $relativePath, string $filename, int $pageNumber): string { $link = $this->outputPathResolver->link( - $file->getRelativePath(), - $file->getFilenameWithoutExtension(), + $relativePath, + $filename, 'html', $pageNumber, $this->prefix, diff --git a/src/File/OutputFile.php b/src/File/OutputFile.php index 725b2c98..b68c5dd5 100644 --- a/src/File/OutputFile.php +++ b/src/File/OutputFile.php @@ -22,9 +22,11 @@ class OutputFile private $prefix; - public function __construct(InputFile $inputFile, $path, $name, $extension, $contents, $data, $page = 1, $prefix = '') + public function __construct(?InputFile $inputFile, $path, $name, $extension, $contents, $data, $page = 1, $prefix = '') { - $this->setInputFile($inputFile, $data); + if ($inputFile !== null) { + $this->setInputFile($inputFile, $data); + } $this->path = $path; $this->name = $name; $this->extension = $extension; diff --git a/src/Handlers/PaginatedPageHandler.php b/src/Handlers/PaginatedPageHandler.php index 41ba22a5..ed1bd4ce 100644 --- a/src/Handlers/PaginatedPageHandler.php +++ b/src/Handlers/PaginatedPageHandler.php @@ -3,7 +3,9 @@ namespace TightenCo\Jigsaw\Handlers; use Illuminate\Support\Str; +use Illuminate\Support\Collection; use TightenCo\Jigsaw\Collection\CollectionPaginator; +use TightenCo\Jigsaw\File\InputFile; use TightenCo\Jigsaw\File\OutputFile; use TightenCo\Jigsaw\File\TemporaryFilesystem; use TightenCo\Jigsaw\PageData; @@ -42,35 +44,67 @@ public function shouldHandle($file) return isset($content->frontMatter['pagination']); } - public function handle($file, PageData $pageData) + public function handle($file, PageData $pageData): Collection { $page = $pageData->page; $page->addVariables($this->getPageVariables($file)); $collection = $page->pagination->collection; $prefix = $page->pagination->prefix - ?: $page->collections->{$collection}->prefix + ?: $page->collections->{$collection}?->prefix ?: $page->prefix ?: ''; + $perPage = $page->pagination->perPage + ?: $page->collections->{$collection}?->perPage + ?: $page->perPage + ?: 10; + $extension = strtolower($file->getExtension()); + $outputExtension = ($extension == 'php' || $extension == 'md') ? 'html' : $extension; - return $this->paginator->paginate( - $file, + return $this->buildOutputFiles( + $file->getRelativePath(), + $file->getFilenameWithoutExtension(), + $outputExtension, $pageData->get($collection), - $page->pagination->perPage - ?: $page->collections->{$collection}->perPage - ?: $page->perPage - ?: 10, + $perPage, + $prefix, + $pageData, + fn ($pageData) => $this->renderFile($file, $pageData), + $file, + ); + } + + public function handleDefinition(string $relativePath, string $filename, string $template, $items, int $perPage, PageData $pageData): Collection + { + return $this->buildOutputFiles( + $relativePath, + $filename, + 'html', + $items, + $perPage, + '', + $pageData, + fn ($pageData) => $this->view->renderView($template, $pageData), + ); + } + + private function buildOutputFiles(string $relativePath, string $filename, string $extension, $items, int $perPage, string $prefix, PageData $pageData, callable $renderer, ?InputFile $inputFile = null): Collection + { + return $this->paginator->paginate( + $relativePath, + $filename, + $items, + $perPage, $prefix, - )->map(function ($page) use ($file, $pageData, $prefix) { + )->map(function ($page) use ($relativePath, $filename, $extension, $pageData, $prefix, $renderer, $inputFile) { $pageData->setPagePath($page->current); $pageData->put('pagination', $page); - $extension = strtolower($file->getExtension()); return new OutputFile( - $file, - $file->getRelativePath(), - $file->getFilenameWithoutExtension(), - ($extension == 'php' || $extension == 'md') ? 'html' : $extension, - $this->render($file, $pageData), + $inputFile, + $relativePath, + $filename, + $extension, + $renderer($pageData), $pageData, $page->currentPage, $prefix, @@ -83,7 +117,7 @@ private function getPageVariables($file) return $this->parser->getFrontMatter($file->getContents()); } - private function render($file, $pageData) + private function renderFile($file, $pageData) { $bladeContent = $this->parser->getBladeContent($file->getContents()); $bladeFilePath = $this->temporaryFilesystem->put( diff --git a/src/Jigsaw.php b/src/Jigsaw.php index 209d75fe..bd6fc7e3 100644 --- a/src/Jigsaw.php +++ b/src/Jigsaw.php @@ -28,6 +28,8 @@ class Jigsaw protected $verbose; + protected $pendingPages = []; + protected static $commands = []; public function __construct( @@ -64,6 +66,15 @@ public static function addUserCommands($app, $container) $app->addCommands(array_map(fn ($command) => new $command($container), self::$commands)); } + public function paginateCollection(string $path, string $collection, string $template, int $perPage = 10, array $variables = []): self + { + $this->setConfig("collections.{$collection}", []); + + $this->pendingPages[] = compact('path', 'collection', 'template', 'perPage', 'variables'); + + return $this; + } + protected function buildCollections() { $this->remoteItemLoader->write($this->siteData->collections, $this->getSourcePath()); @@ -81,6 +92,7 @@ protected function buildSite($useCache) $this->getSourcePath(), $this->getDestinationPath(), $this->siteData, + $this->pendingPages, ); $this->outputPaths = $this->pageInfo->keys(); diff --git a/src/SiteBuilder.php b/src/SiteBuilder.php index 7b67e79d..ddbfd2b7 100644 --- a/src/SiteBuilder.php +++ b/src/SiteBuilder.php @@ -6,6 +6,7 @@ use TightenCo\Jigsaw\Console\ConsoleOutput; use TightenCo\Jigsaw\File\Filesystem; use TightenCo\Jigsaw\File\InputFile; +use TightenCo\Jigsaw\Handlers\PaginatedPageHandler; class SiteBuilder { @@ -42,10 +43,11 @@ public function setUseCache($useCache) return $this; } - public function build($source, $destination, $siteData) + public function build($source, $destination, $siteData, $pendingPages = []) { $this->prepareDirectory($this->cachePath, ! $this->useCache); - $generatedFiles = $this->generateFiles($source, $siteData); + $generatedFiles = $this->generateFiles($source, $siteData) + ->merge($this->generatePaginatedPages($pendingPages, $siteData)); $this->prepareDirectory($destination, true); $outputFiles = $this->writeFiles($generatedFiles, $destination); $this->cleanup(); @@ -99,6 +101,34 @@ private function generateFiles($source, $siteData) return $files; } + private function generatePaginatedPages($pendingPages, $siteData) + { + if (empty($pendingPages)) { + return collect(); + } + + $handler = collect($this->handlers)->first(fn ($h) => $h instanceof PaginatedPageHandler); + + return collect($pendingPages)->flatMap(function ($def) use ($handler, $siteData) { + $relativePath = ltrim(dirname($def['path']), '.'); + $filename = basename($def['path']); + $meta = $this->getMetaDataForPath($relativePath, $filename, $siteData->page->baseUrl); + + $pageData = PageData::withPageMetaData($siteData, $meta); + Container::getInstance()->instance('pageData', $pageData); + $pageData->page->addVariables($def['variables']); + + return $handler->handleDefinition( + $relativePath, + $filename, + $def['template'], + $siteData->get($def['collection']), + $def['perPage'], + $pageData, + ); + }); + } + private function writeFiles($files, $destination) { $this->consoleOutput->writeWritingFiles(); @@ -106,7 +136,7 @@ private function writeFiles($files, $destination) return $files->mapWithKeys(function ($file) use ($destination) { $outputLink = $this->writeFile($file, $destination); - return [$outputLink => $file->inputFile()->getPageData()]; + return [$outputLink => $file->inputFile()?->getPageData()]; }); } @@ -136,6 +166,16 @@ private function getHandler($file) }); } + private function getMetaDataForPath(string $relativePath, string $filename, ?string $baseUrl): array + { + $path = rightTrimPath($this->outputPathResolver->link($relativePath, $filename, 'html')); + $url = rightTrimPath($baseUrl ?? '') . '/' . trimPath($path); + $extension = 'html'; + $modifiedTime = time(); + + return compact('filename', 'baseUrl', 'path', 'relativePath', 'extension', 'url', 'modifiedTime'); + } + private function getMetaData($file, $baseUrl) { $filename = $file->getFilenameWithoutExtension(); diff --git a/src/View/ViewRenderer.php b/src/View/ViewRenderer.php index 6b380fe6..a2aa62e2 100644 --- a/src/View/ViewRenderer.php +++ b/src/View/ViewRenderer.php @@ -25,6 +25,11 @@ public function render($path, $data) return app('view')->file($path, $data->all())->render(); } + public function renderView(string $view, $data): string + { + return app('view')->make($view, $data->all())->render(); + } + public function renderString($string) { return app('blade.compiler')->compileString($string); diff --git a/tests/DynamicCollectionPaginationTest.php b/tests/DynamicCollectionPaginationTest.php new file mode 100644 index 00000000..267593bb --- /dev/null +++ b/tests/DynamicCollectionPaginationTest.php @@ -0,0 +1,188 @@ +setupSource([ + '_layouts' => [ + 'tag.blade.php' => '@foreach($pagination->items as $post){{ $post->getFilename() }}@endforeach', + ], + '_posts' => [ + 'post1.blade.php' => "---\ntags:\n - php\n---\n", + 'post2.blade.php' => "---\ntags:\n - php\n---\n", + 'post3.blade.php' => "---\ntags:\n - php\n---\n", + 'post4.blade.php' => "---\ntags:\n - laravel\n---\n", + 'post5.blade.php' => "---\ntags:\n - laravel\n---\n", + ], + ]); + + $this->app['events']->afterCollections(function (Jigsaw $jigsaw) { + $posts = $jigsaw->getCollection('posts'); + + $jigsaw->setConfig('tag_php', $posts->filter(fn ($post) => in_array('php', $post->tags ?? []))); + $jigsaw->paginateCollection( + path: 'tags/php', + collection: 'tag_php', + template: '_layouts.tag', + perPage: 2, + ); + + $jigsaw->setConfig('tag_laravel', $posts->filter(fn ($post) => in_array('laravel', $post->tags ?? []))); + $jigsaw->paginateCollection( + path: 'tags/laravel', + collection: 'tag_laravel', + template: '_layouts.tag', + perPage: 2, + ); + }); + + $this->buildSite($files, ['collections' => ['posts' => []]], $pretty = true); + + $phpPage1 = $this->clean($files->getChild('build/tags/php/index.html')->getContent()); + $phpPage2 = $this->clean($files->getChild('build/tags/php/2/index.html')->getContent()); + $laravelPage1 = $this->clean($files->getChild('build/tags/laravel/index.html')->getContent()); + + $this->assertStringContainsString('post', $phpPage1); + $this->assertStringContainsString('post', $phpPage2); + $this->assertStringContainsString('post', $laravelPage1); + $this->assertFileMissing($this->tmpPath('build/tags/php/3/index.html')); + $this->assertFileMissing($this->tmpPath('build/tags/laravel/2/index.html')); + } + + #[Test] + public function paginate_collection_generates_correct_page_count() + { + $files = $this->setupSource([ + '_layouts' => [ + 'items.blade.php' => '{{ $pagination->totalPages }}', + ], + '_items' => [ + 'item1.blade.php' => '', + 'item2.blade.php' => '', + 'item3.blade.php' => '', + 'item4.blade.php' => '', + 'item5.blade.php' => '', + ], + ]); + + $this->app['events']->afterCollections(function (Jigsaw $jigsaw) { + $jigsaw->paginateCollection( + path: 'list', + collection: 'items', + template: '_layouts.items', + perPage: 2, + ); + }); + + $this->buildSite($files, ['collections' => ['items' => []]], $pretty = true); + + $this->assertEquals('3', $this->clean($files->getChild('build/list/index.html')->getContent())); + $this->assertEquals('3', $this->clean($files->getChild('build/list/2/index.html')->getContent())); + $this->assertEquals('3', $this->clean($files->getChild('build/list/3/index.html')->getContent())); + $this->assertFileMissing($this->tmpPath('build/list/4/index.html')); + } + + #[Test] + public function paginate_collection_exposes_pagination_navigation_links() + { + $files = $this->setupSource([ + '_layouts' => [ + 'items.blade.php' => '{{ $pagination->previous }}|{{ $pagination->next }}', + ], + '_items' => [ + 'item1.blade.php' => '', + 'item2.blade.php' => '', + 'item3.blade.php' => '', + ], + ]); + + $this->app['events']->afterCollections(function (Jigsaw $jigsaw) { + $jigsaw->paginateCollection( + path: 'list', + collection: 'items', + template: '_layouts.items', + perPage: 1, + ); + }); + + $this->buildSite($files, ['collections' => ['items' => []]], $pretty = true); + + $this->assertEquals('|/list/2', $this->clean($files->getChild('build/list/index.html')->getContent())); + $this->assertEquals('/list|/list/3', $this->clean($files->getChild('build/list/2/index.html')->getContent())); + $this->assertEquals('/list/2|', $this->clean($files->getChild('build/list/3/index.html')->getContent())); + } + + #[Test] + public function paginate_collection_passes_extra_variables_to_template() + { + $files = $this->setupSource([ + '_layouts' => [ + 'items.blade.php' => '{{ $page->label }}', + ], + '_items' => [ + 'item1.blade.php' => '', + ], + ]); + + $this->app['events']->afterCollections(function (Jigsaw $jigsaw) { + $jigsaw->paginateCollection( + path: 'list', + collection: 'items', + template: '_layouts.items', + perPage: 10, + variables: ['label' => 'Hello from variables'], + ); + }); + + $this->buildSite($files, ['collections' => ['items' => []]], $pretty = true); + + $this->assertEquals('Hello from variables', $this->clean($files->getChild('build/list/index.html')->getContent())); + } + + #[Test] + public function paginate_collection_can_be_called_multiple_times_for_multiple_collections() + { + $files = $this->setupSource([ + '_layouts' => [ + 'items.blade.php' => '{{ $pagination->totalPages }}', + ], + '_items' => [ + 'item1.blade.php' => '', + 'item2.blade.php' => '', + 'item3.blade.php' => '', + ], + ]); + + $this->app['events']->afterCollections(function (Jigsaw $jigsaw) { + $jigsaw->paginateCollection( + path: 'list-a', + collection: 'items', + template: '_layouts.items', + perPage: 1, + ); + $jigsaw->paginateCollection( + path: 'list-b', + collection: 'items', + template: '_layouts.items', + perPage: 2, + ); + }); + + $this->buildSite($files, ['collections' => ['items' => []]], $pretty = true); + + $this->assertEquals('3', $this->clean($files->getChild('build/list-a/index.html')->getContent())); + $this->assertEquals('3', $this->clean($files->getChild('build/list-a/3/index.html')->getContent())); + $this->assertFileMissing($this->tmpPath('build/list-a/4/index.html')); + + $this->assertEquals('2', $this->clean($files->getChild('build/list-b/index.html')->getContent())); + $this->assertEquals('2', $this->clean($files->getChild('build/list-b/2/index.html')->getContent())); + $this->assertFileMissing($this->tmpPath('build/list-b/3/index.html')); + } +} From 23713064918394ef44c20d7e1c60a049910197cd Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 24 Apr 2026 18:22:32 +0000 Subject: [PATCH 2/3] Format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ..................................................✓......................... ............................................. ──────────────────────────────────────────────────────────────────── Laravel FIXED ................................... 121 files, 1 style issue fixed ✓ src/Handlers/PaginatedPageHandler.php unary_operator_spaces, not_operator… ) --- src/Handlers/PaginatedPageHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Handlers/PaginatedPageHandler.php b/src/Handlers/PaginatedPageHandler.php index ed1bd4ce..155a8f99 100644 --- a/src/Handlers/PaginatedPageHandler.php +++ b/src/Handlers/PaginatedPageHandler.php @@ -2,8 +2,8 @@ namespace TightenCo\Jigsaw\Handlers; -use Illuminate\Support\Str; use Illuminate\Support\Collection; +use Illuminate\Support\Str; use TightenCo\Jigsaw\Collection\CollectionPaginator; use TightenCo\Jigsaw\File\InputFile; use TightenCo\Jigsaw\File\OutputFile; From 795f6ca286c84ac25125ab4e660038df8d663368 Mon Sep 17 00:00:00 2001 From: Nico Devs Date: Fri, 24 Apr 2026 16:05:08 -0300 Subject: [PATCH 3/3] Improve test --- tests/DynamicCollectionPaginationTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/DynamicCollectionPaginationTest.php b/tests/DynamicCollectionPaginationTest.php index 267593bb..a22e600d 100644 --- a/tests/DynamicCollectionPaginationTest.php +++ b/tests/DynamicCollectionPaginationTest.php @@ -49,9 +49,11 @@ public function can_paginate_a_dynamically_registered_collection() $phpPage2 = $this->clean($files->getChild('build/tags/php/2/index.html')->getContent()); $laravelPage1 = $this->clean($files->getChild('build/tags/laravel/index.html')->getContent()); - $this->assertStringContainsString('post', $phpPage1); - $this->assertStringContainsString('post', $phpPage2); - $this->assertStringContainsString('post', $laravelPage1); + $this->assertStringContainsString('post1', $phpPage1); + $this->assertStringNotContainsString('post1', $laravelPage1); + $this->assertStringContainsString('post4', $laravelPage1); + $this->assertStringNotContainsString('post4', $phpPage1); + $this->assertStringNotContainsString('post4', $phpPage2); $this->assertFileMissing($this->tmpPath('build/tags/php/3/index.html')); $this->assertFileMissing($this->tmpPath('build/tags/laravel/2/index.html')); }