diff --git a/.gitignore b/.gitignore index 5afbaf36..8ff296eb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ composer.lock /vendor .phpunit.result.cache .phpunit.cache +.idea/ diff --git a/composer.json b/composer.json index 547dc0a7..2e157103 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ }, "require-dev": { "orchestra/testbench": "^8.0 || ^9.0", - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.0", + "laravel/pint": "^1.17" }, "config": { "allow-plugins": { diff --git a/config/seo-pro.php b/config/seo-pro.php index fc443d03..42161fce 100644 --- a/config/seo-pro.php +++ b/config/seo-pro.php @@ -22,6 +22,11 @@ 'enabled' => true, 'url' => 'sitemap.xml', 'expire' => 60, + 'pagination' => [ + 'enabled' => false, + 'url' => 'sitemap_{page}.xml', + 'limit' => 100, + ], ], 'humans' => [ diff --git a/resources/views/generated/sitemap_index.antlers.html b/resources/views/generated/sitemap_index.antlers.html new file mode 100644 index 00000000..205c47eb --- /dev/null +++ b/resources/views/generated/sitemap_index.antlers.html @@ -0,0 +1,8 @@ +{{ xml_header }} + +{{ sitemaps }} + + {{ url }} + +{{ /sitemaps }} + diff --git a/routes/web.php b/routes/web.php index bbdbb345..6a9515e1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,5 +2,6 @@ use Statamic\SeoPro\Http\Controllers; -Route::get(config('statamic.seo-pro.sitemap.url'), [Controllers\SitemapController::class, 'show']); +Route::get(config('statamic.seo-pro.sitemap.url'), [Controllers\SitemapController::class, 'index']); +Route::get(config('statamic.seo-pro.sitemap.pagination.url'), [Controllers\SitemapController::class, 'show'])->name('statamic.seo-pro.sitemap.page.show'); Route::get(config('statamic.seo-pro.humans.url'), [Controllers\HumansController::class, 'show']); diff --git a/src/Http/Controllers/SitemapController.php b/src/Http/Controllers/SitemapController.php index 90caf6e6..3baa51de 100755 --- a/src/Http/Controllers/SitemapController.php +++ b/src/Http/Controllers/SitemapController.php @@ -9,16 +9,47 @@ class SitemapController extends Controller { - public function show() + public function index() { abort_unless(config('statamic.seo-pro.sitemap.enabled'), 404); $cacheUntil = Carbon::now()->addMinutes(config('statamic.seo-pro.sitemap.expire')); - $content = Cache::remember(Sitemap::CACHE_KEY, $cacheUntil, function () { + if (config('statamic.seo-pro.sitemap.pagination.enabled', false)) { + $content = Cache::remember(Sitemap::CACHE_KEY.'_index', $cacheUntil, function () { + return view('seo-pro::sitemap_index', [ + 'xml_header' => '', + 'sitemaps' => Sitemap::paginatedSitemaps(), + ])->render(); + }); + } else { + $content = Cache::remember(Sitemap::CACHE_KEY, $cacheUntil, function () { + return view('seo-pro::sitemap', [ + 'xml_header' => '', + 'pages' => Sitemap::pages(), + ])->render(); + }); + } + + return response($content)->header('Content-Type', 'text/xml'); + } + + public function show($page) + { + abort_unless(config('statamic.seo-pro.sitemap.enabled'), 404); + abort_unless(config('statamic.seo-pro.sitemap.pagination.enabled'), 404); + abort_unless(filter_var($page, FILTER_VALIDATE_INT), 404); + + $cacheUntil = Carbon::now()->addMinutes(config('statamic.seo-pro.sitemap.expire')); + + $cacheKey = Sitemap::CACHE_KEY.'_'.$page; + + $content = Cache::remember($cacheKey, $cacheUntil, function () use ($page) { + abort_if(empty($pages = Sitemap::paginatedPages($page)), 404); + return view('seo-pro::sitemap', [ - 'pages' => Sitemap::pages(), 'xml_header' => '', + 'pages' => $pages, ])->render(); }); diff --git a/src/Sitemap/Sitemap.php b/src/Sitemap/Sitemap.php index 84d8b0c5..df6c55a8 100644 --- a/src/Sitemap/Sitemap.php +++ b/src/Sitemap/Sitemap.php @@ -4,6 +4,7 @@ use Statamic\Facades\Blink; use Statamic\Facades\Collection; +use Statamic\Facades\Entry; use Statamic\Facades\Taxonomy; use Statamic\SeoPro\Cascade; use Statamic\SeoPro\GetsSectionDefaults; @@ -31,6 +32,63 @@ public static function pages() ->toArray(); } + public static function paginatedPages(int $page) + { + $sitemap = new static; + + $perPage = config('statamic.seo-pro.sitemap.pagination.limit', 100); + $offset = ($page - 1) * $perPage; + $remaining = $perPage; + + $pages = collect([]); + + $entryCount = $sitemap->publishedEntriesCount() - 1; + + if ($offset < $entryCount) { + $entries = $sitemap->publishedEntries()->skip($offset)->take($perPage); + + if ($entries->count() < $remaining) { + $remaining -= $entries->count(); + } + + $pages = $pages->merge($entries); + } + + if ($remaining > 0) { + $offset = max($offset - $entryCount, 0); + + $pages = $pages->merge( + collect($sitemap->publishedTerms()) + ->merge($sitemap->publishedCollectionTerms()) + ->skip($offset) + ->take($remaining) + ); + } + + if ($pages->isEmpty()) { + return []; + } + + return $sitemap->getPages($pages) + ->values() + ->map + ->toArray(); + } + + public static function paginatedSitemaps() + { + $sitemap = new static; + + // would be nice to make terms a count query rather than getting the count from the terms collection + $count = $sitemap->publishedEntriesCount() + $sitemap->publishedTerms()->count() + $sitemap->publishedCollectionTerms()->count(); + + $sitemapCount = ceil($count / config('statamic.seo-pro.sitemap.pagination.limit', 100)); + + return collect(range(1, $sitemapCount)) + ->map(fn ($page) => ['url' => route('statamic.seo-pro.sitemap.page.show', ['page' => $page])]) + ->all(); + } + protected function getPages($items) { return $items @@ -54,20 +112,33 @@ protected function getPages($items) ->filter(); } - protected function publishedEntries() + private function publishedEntriesQuery() { - return Collection::all() - ->flatMap(function ($collection) { + $collections = Collection::all() + ->map(function ($collection) { return $collection->cascade('seo') !== false - ? $collection->queryEntries()->get() - : collect(); + ? $collection->handle() + : false; }) - ->filter(function ($entry) { - return $entry->status() === 'published'; - }) - ->reject(function ($entry) { - return is_null($entry->uri()); - }); + ->filter() + ->values() + ->all(); + + return Entry::query() + ->whereIn('collection', $collections) + ->whereNotNull('uri') + ->whereStatus('published') + ->orderBy('uri'); + } + + protected function publishedEntries() + { + return $this->publishedEntriesQuery()->lazy(); + } + + protected function publishedEntriesCount() + { + return $this->publishedEntriesQuery()->count(); } protected function publishedTerms() diff --git a/tests/SitemapTest.php b/tests/SitemapTest.php index 4eddc968..98df06ff 100644 --- a/tests/SitemapTest.php +++ b/tests/SitemapTest.php @@ -44,15 +44,15 @@ public function it_outputs_sitemap_xml() - http://cool-runnings.com/magic - $today + http://cool-runnings.com/about + 2020-01-17 monthly 0.5 - http://cool-runnings.com/nectar - $today + http://cool-runnings.com/articles + 2020-01-17 monthly 0.5 @@ -65,15 +65,15 @@ public function it_outputs_sitemap_xml() - http://cool-runnings.com/about - 2020-01-17 + http://cool-runnings.com/magic + $today monthly 0.5 - http://cool-runnings.com/articles - 2020-01-17 + http://cool-runnings.com/nectar + $today monthly 0.5 @@ -219,4 +219,150 @@ protected function getPagesFromSitemapXml($content) return collect($data['url']); } + + /** @test */ + public function it_outputs_paginated_sitemap_index_xml() + { + config()->set('statamic.seo-pro.sitemap.pagination.enabled', true); + config()->set('statamic.seo-pro.sitemap.pagination.limit', 5); + + $content = $this + ->get('/sitemap.xml') + ->assertOk() + ->assertHeader('Content-Type', 'text/xml; charset=UTF-8') + ->getContent(); + + $expected = <<<'EOT' + + + + + http://cool-runnings.com/sitemap_1.xml + + + + http://cool-runnings.com/sitemap_2.xml + + + + +EOT; + + $this->assertEquals($expected, $content); + } + + /** @test */ + public function it_outputs_paginated_sitemap_page_xml() + { + config()->set('statamic.seo-pro.sitemap.pagination.enabled', true); + config()->set('statamic.seo-pro.sitemap.pagination.limit', 5); + + $content = $this + ->get('/sitemap_1.xml') + ->assertOk() + ->assertHeader('Content-Type', 'text/xml; charset=UTF-8') + ->getContent(); + + $this->assertCount(5, $this->getPagesFromSitemapXml($content)); + + $today = now()->format('Y-m-d'); + + $expected = <<<"EOT" + + + + + http://cool-runnings.com + 2020-11-24 + monthly + 0.5 + + + + http://cool-runnings.com/about + 2020-01-17 + monthly + 0.5 + + + + http://cool-runnings.com/articles + 2020-01-17 + monthly + 0.5 + + + + http://cool-runnings.com/dance + $today + monthly + 0.5 + + + + http://cool-runnings.com/magic + $today + monthly + 0.5 + + + + +EOT; + + $this->assertEquals($expected, $content); + + $content = $this + ->get('/sitemap_2.xml') + ->assertOk() + ->assertHeader('Content-Type', 'text/xml; charset=UTF-8') + ->getContent(); + + $this->assertCount(2, $this->getPagesFromSitemapXml($content)); + + $today = now()->format('Y-m-d'); + + $expected = <<<"EOT" + + + + + http://cool-runnings.com/nectar + $today + monthly + 0.5 + + + + http://cool-runnings.com/topics + 2020-01-20 + monthly + 0.5 + + + + +EOT; + + $this->assertEquals($expected, $content); + } + + /** @test */ + public function it_404s_on_invalid_pagination_urls() + { + config()->set('statamic.seo-pro.sitemap.pagination.enabled', true); + config()->set('statamic.seo-pro.sitemap.pagination.limit', 5); + + $this + ->get('/sitemap_3.xml') + ->assertNotFound(); + + $this + ->get('/sitemap_3a.xml') + ->assertNotFound(); + + $this + ->get('/sitemap_a.xml') + ->assertNotFound(); + } }