Skip to content

Instantly share code, notes, and snippets.

@jasonvarga
Last active June 26, 2023 22:10
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jasonvarga/256f293f8f55bf564c907a335a2f40f3 to your computer and use it in GitHub Desktop.
Save jasonvarga/256f293f8f55bf564c907a335a2f40f3 to your computer and use it in GitHub Desktop.
Statamic SSG Pagination

Statamic SSG Pagination

We will likely introduce better native support for pagination at some point, but for now, this is an okay workaround that only requires a little code.

This will change ?page=2 pagination URLs to /page/2 URLs, so the SSG is able to recognize them as their own pages.

Paginator

Copy UrlPaginator into app/UrlPaginator.php.

You can put it somewhere else, but be sure to update the namespace in the class itself, and any references to it.

This class overrides Statamic's paginator and changes the /url?page=2 urls to /url/page/2 urls.

Service Provider

Copy the contents of the boot method into your own AppServiceProvider.

In here it will:

  • Swap out Statamic's paginator for the url based one
  • Tell the paginator to look at the /page/{page} part of the url for the page number, rather than looking for a ?page= query string.
  • Add any paginator urls to the SSG's "urls" config.

Add routes

Since this method of pagination means there will be separate pages, you'll need a route.

You can copy the route in web.php and tweak as necessary. Make sure to keep page/{page} in there.

Rinse and repeat for any other sections of your site that have pagination.

Adjust your URL logic

In the service provider, you'll see this block:

$config['urls'] = array_merge(
    $config['urls'],
    $this->articleUrls(),
    // $this->blogUrls(),
    // $this->tagUrls(),
    // etc
);

This is going to dynamically add URLs to the ssg.php config file.

  • Copy over the articleUrls method.
  • Tweak the naming for what makes sense for you.
  • Adjust the URL, query, and perPage in order to correspond to your collection tag.
  • Repeat for any other sections of your site that have pagination.
<?php
namespace App\Providers;
use App\UrlPaginator;
use Illuminate\Support\ServiceProvider;
use Statamic\Extensions\Pagination\LengthAwarePaginator;
use Statamic\Facades\Entry;
use Statamic\StaticSite\Generator;
class AppServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
if ($this->app->runningInConsole()) {
$this->bootSsg();
}
}
private function bootSsg()
{
$this->app->extend(LengthAwarePaginator::class, function ($paginator) {
$options = $paginator->getOptions();
$options['path'] = preg_replace('/\/page\/\d+$/', '', $options['path']);
return $this->app->makeWith(UrlPaginator::class, [
'items' => $paginator->getCollection(),
'total' => $paginator->total(),
'perPage' => $paginator->perPage(),
'currentPage' => $paginator->currentPage(),
'options' => $options,
]);
});
UrlPaginator::currentPageResolver(function () {
return optional($this->app['request']->route())->parameter('page');
});
$this->app->beforeResolving(Generator::class, function ($generator) {
$config = config('statamic.ssg');
$config['urls'] = array_merge(
$config['urls'],
$this->articleUrls(),
// $this->blogUrls(),
// $this->tagUrls(),
// etc
);
config(['statamic.ssg' => $config]);
});
}
private function articleUrls()
{
// The URL of the listing.
$url = '/articles';
// The number of entries per page, according to your collection tag.
$perPage = 10;
// The total number of entries in the collection.
// Make sure to mimic whatever params/filters are on the collection tag.
$total = Entry::query()->where('collection', 'articles')->where('status', 'published')->count();
return collect(range(1, ceil($total / $perPage)))
->map(fn ($page) => $url.'/page/'.$page)
->all();
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
}
<?php
namespace App;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Statamic\Extensions\Pagination\LengthAwarePaginator;
class UrlPaginator extends LengthAwarePaginator
{
public function url($page)
{
if ($page <= 0) {
$page = 1;
}
$url = $this->path().'/'.$this->pageName.'/'.$page;
if (Str::contains($this->path(), '?') || count($this->query)) {
$url .= '?'.Arr::query($this->query);
}
return $url.$this->buildFragment();
}
}
<?php
Route::statamic('articles/page/{page}', 'articles.index', ['load' => 'id-of-articles-page']);
@globalexport
Copy link

globalexport commented Apr 20, 2021

Hi Jason!

Thank you for your code. I am testing it right now.
The url rewrite '/articles/page/x' is working correctly in the "normal/dynamic" Statamic installation, but there are no folders generated in the static output.

I would expect a file structure of the following kind to make it work:

articles
  ↳ page
     ↳ 1
       ↳ index.html
     ↳ 2
        ↳ index.html
     ↳ 3
       ↳ index.html
     ...

Am I missing something?

This is the ssg configuration I am logging when running the SSG process:

{
  "base_url":"https://static-test.site/",
  "destination":"/var/www/html/ssg-statamic-cms/storage/app/static",
  "copy":{
    "/var/www/html/ssg-statamic-cms/public/css":"css",
    "/var/www/html/ssg-statamic-cms/public/js":"js",
    "/var/www/html/ssg-statamic-cms/public/assets":"assets"
  },
  "symlinks":[],
  "urls":[
    "/articles/page/1",
    "/articles/page/2"
  ],
  "exclude":[],
  "glide":{
    "directory":"img"
  }
} 

Versions:
Statamic: 3.1.9
SSG-Addon: 0.4.0

@jasonvarga
Copy link
Author

Read over the readme in this gist carefully.

Did you change line 58 of AppServiceProvider.php above? (The number of entries per page)
Don't add urls to your config file. This thing is supposed to add to them dynamically.

@globalexport
Copy link

globalexport commented Apr 20, 2021

Hi Jason,

Thanks for your response.

No, I did not change line 58 of AppServiceProvider.php. I have 11 test articles and I expected the mentioned folder structure in the output folder ("destination") of the SSG process. But nothing is happening there. The articles remain "flat" within one folder.

The ssg configuration I posted above is the dynamically generated configuration. I did not add the urls manually: line 47, $config in AppServiceProvider.php.

@jasonvarga
Copy link
Author

Ah I misread you said you logged that. Explains why it was json 🤪

If you visit /articles/page/1 or /2 manually on your not-static site, does it work?

@globalexport
Copy link

No, this does not work. It only works if I manually create the folder structure and place a test index.html into the folders.
The "regular" Statamic site works like a charme (it uses the web route you mentioned).

Do you see the an adjusted folder structure in your destination path? How is that possible... :)

@jasonvarga
Copy link
Author

When you run the ssg command, do you see /articles/page/1 in the list?

@globalexport
Copy link

globalexport commented Apr 21, 2021

Hi Jason,

Sorry for the late response and thank you for taking your time to help. Much appreciated!
Sadly, the generator did not produce an output like /articles/page/1.

The only structure is:
/articles
/articles/{differentTitles}

Did you create your solution with v0.4.0 of the SSG addon?

@globalexport
Copy link

I guess my configuration is not being picked up by the Generator. When logging the configuration in its constructor at
/vendor/statamic/ssg/src/Generator.php I do not see the adjusted configuration of the callback defined in:

$this->app->beforeResolving(Generator::class, function ($generator)
{
...
}

@globalexport
Copy link

In my case, beforeResolving seemed to get executed after the constructor. I could not find out the reason for this behaviour.

I solved the issue by overwriting the constructor of the Generator.php to actively fetch the current configuration.

@globalexport
Copy link

globalexport commented Apr 28, 2021

Hi @jasonvarga,

The pagination solution also takes effect on the collection pagination in the Control Panel.
I ran into this issue today (please see here).

How could we differentiate between CP and frontend? Do you have an idea?

@jasonvarga
Copy link
Author

Nice find. I've updated the gist. Basically moved everything into a separate method. Only do anything if it's running in the console.

@globalexport
Copy link

I deleted my last post as it was my fault, I assume. I did not add /blog etc to the urls of the generator. I now tested a small set of my data and it worked.

@fitzage
Copy link

fitzage commented Jun 7, 2021

I’ve been through this several times, and I think I have it set up right. /page/{n} style pagination isn’t working on the non-static site, but I tried to generate the ssg and got this error:

Call to undefined method Illuminate\Foundation\Application::beforeResolving()

@jasonvarga
Copy link
Author

What version of Laravel are you running?

@fitzage
Copy link

fitzage commented Jun 7, 2021

Umm…I may have upgraded too far by accidentally using --with-all-dependencies? According to my composer.json, it’s ^7.0.

I’m not doing anything else with Laravel. Just Statamic.

Edit:
This is more precise:

❯ php artisan --version
Laravel Framework 7.30.4

@jasonvarga
Copy link
Author

Ah looks like beforeResolving is only available in Laravel 8.

@fitzage
Copy link

fitzage commented Jun 7, 2021

Hmm, OK. Apparently if you’re using 8, there shouldn’t be any issues with upgrading, eh?

@jasonvarga
Copy link
Author

You can see what changes are necessary at the project level by checking out statamic/statamic#18
Assuming you haven't done any custom stuff, you should be able to drop the new versions right over the top for most of them.

As for Statamic itself, it's fine with Laravel 8.

@fitzage
Copy link

fitzage commented Jun 11, 2021

I've been fighting with the index pages being generated as a random page. Like go to /blog, and it might actually be showing you page 3, 4, 5, or even page 8 when you only have 6 pages. I'm not filing this as an official issue, because I think it might be something related to this instead of the SSG itself, but I could be wrong.

Anyway, I found a workaround that barely seems to make any sense.

I added the routes to web.php that were needed according to these instructions. In my case it looks like this, because I paginated several sections:

Route::statamic('blog/page/{page}', 'blog', ['load' => '1a0a3f42-ce48-4da4-8f62-48c1234f6c6d']);
Route::statamic('about/press/page/{page}', 'newsroom', ['load' => '1e326f66-fdd3-46cb-bfe7-2a4106836905']);
Route::statamic('about/press/press-releases/page/{page}', 'newsroom', ['load' => 'dd53fc68-03c6-4647-80ff-08069578f4e1']);
Route::statamic('about/press/news-mentions/page/{page}', 'newsroom', ['load' => '68d2d730-719d-4d42-abb3-077ef957edde']);

After trying various unsuccessful workarounds, I came up with one that appears to work: adding an explicit route to web.php for each of the index pages. So those additional routes look like this:

Route::statamic('blog', 'blog', ['load' => '1a0a3f42-ce48-4da4-8f62-48c1234f6c6d']);
Route::statamic('about/press', 'newsroom', ['load' => '1e326f66-fdd3-46cb-bfe7-2a4106836905']);
Route::statamic('about/press/press-releases', 'newsroom', ['load' => 'dd53fc68-03c6-4647-80ff-08069578f4e1']);
Route::statamic('about/press/news-mentions', 'newsroom', ['load' => '68d2d730-719d-4d42-abb3-077ef957edde']);

I'm not 100% sure why, but this seems to solve the problem. I've tested by building locally a few times, then pushed to CloudFlare Pages. It seems all of them are working correctly every time now.

@globalexport
Copy link

globalexport commented Jun 13, 2021

@fitzage

Did you add the root (/blog for instance) to the collection of urls which are collected here?

In my case, I shared a similar experience, because it was not enough to collect the sub-pages only. I mentioned it here.

I needed something like this:

    private static function urls($identifier)
    {
        $url = "/{$identifier}";

        $allUrls = [];

        // ...

        $collect = collect(range(1, ceil($total / $perPage)))
        ->map(fn($page) => $url . '/page/' . $page)
        ->all();

        // merging root and sub-pages here
        $allUrls = array_merge([$url], $collect);
        return $allUrls;
     }

@fitzage
Copy link

fitzage commented Jun 13, 2021

@globalexport Thanks. I did not do that.

It's weird that we should need to do anything special, as this is a standard page that should just be rendered correctly. At any rate, my simpler route addition seems to be working, so I'll just stick with that.

@akutaktau
Copy link

I've been testing this, somehow it doesn't respect multisite layout. Is there a way to make support multisite?

@jasonvarga
Copy link
Author

@edinabazi it seems to still be working. Make sure that you've added the appropriate imports in the service provider.

The use App\UrlPaginator; etc lines at the top.

@edinabazi
Copy link

@jasonvarga I had this line in web.php URL::forceScheme('https'); from god knows when which somehow broke it all. It's working now. Thank you so much!

@tao
Copy link

tao commented Feb 4, 2023

Wrapping the boot command to only run in console is important to ensure the pagination continues to work in the Statamic Control Panel.

    if ($this->app->runningInConsole()) {

Related to bug: Collection pagination not functional in Control Panel

@simonhamp
Copy link

I got this working locally easy enough (tested with ssg:serve) but it took a few steps to get it working on Netlify:

1. Add an .env file to your repo (encrypted)

First of all, I had to create an encrypted .env.production file. See Laravel's instructions on how to do this for more detail (I opted for an encrypted env because it's overall a safer method) - remember the encryption key you use for this file; you'll need it in a later step.

php artisan env:encrypt --env=production --key={some-string-key-appropriate-for-your-preferred-cypher}

The reason for the env file is because this twist on the SSG needs the APP_URL and an APP_KEY to work well. So set those in your .env.production file for the appropriate production values and then encrypt it.

Commit the .env.production.encrypted to your repo. .gitignore the unencrypted .env.production.

2. Add the decryption key to your Netlify environment variables

Add the --key you used in the first step as an environment variable in Netlify called LARAVEL_ENV_ENCRYPTION_KEY (under Deploy settings > Environment variables > Environment variables)

3. Update your Netlify build command

Update your build command (under Deploy settings > Build settings > Build command) to look like this:

php artisan env:decrypt --env=production ; php please ssg:generate --env=production

4. Build

All works nicely for me 👍🏼 (caveat: my site is pretty simple for now)

@mbootsman
Copy link

mbootsman commented May 11, 2023

Having problems to get this to work.

What I did.

  • Added UrlPaginator.php
  • Copied the boot part into my own boot method.
  • Added route in web.php. I have Route::statamic('news/page/{page}', 'news/index', ['load' => 'b9e4bfe3-9c12-4553-b7ef-f43c22ffaa63']); where the id is from the News page that lists the paginated news articles.
    I have a collection news, with articles in there. The news index template is in /view/news/index.antlers.html
  • Adapted URL Logic like this:
private function articleUrls()
    {
        // The URL of the listing.
        $url = '/news';

        // The number of entries per page, according to your collection tag.
        $perPage = 9;

        // The total number of entries in the collection.
        // Make sure to mimic whatever params/filters are on the collection tag.
        $total = Entry::query()->where('collection', 'news')->where('status', 'published')->count();

        return collect(range(1, ceil($total / $perPage)))
            ->map(fn ($page) => $url.'/page/'.$page)
            ->all();
    }

When I generate the site with ssg:generate, the index.html in /news has the 'old' ?page=2 urls.
Do you have an idea what is going wrong here @jasonvarga?

Update:
FIXED!
I upgrade to version 4.6.0
Reapplied the changes mentioned in this gist, and it works!

@simonhamp
Copy link

Note that you may bump into issues when deploying a site in a fresh environment if you're using a database with this logic as the service provider will try to access the database, which you generally shouldn't do in a service provider.

This is because you'll bump into a 'database accessed before migration' issue which may present in slightly horrible fashion.

I got around this by using a simple flag in my .env file:

    public function boot()
    {
-        if ($this->app->runningInConsole()) {
+        if (env('SSG_ENABLED')) {
            $this->bootSsg();
        }
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment