statamic / ssg

The official Statamic Static Site Generator
234 stars 24 forks source link

Pagination is not supported #10

Closed owenconti closed 1 year ago

owenconti commented 4 years ago

My home page template has the following snippet (which I think I took from the v3 docs):

    {{ paginate }}
        <div class="flex justify-between text-sm">
          <a href="{{ prev_page }}">⬅ Previous</a>

          {{ current_page }} of {{ total_pages }} pages
          (There {{ total_items == 1 ? 'is' : 'are' }} {{ total_items }} {{ 'post' | plural:total_items }})
          <a href="{{ next_page }}">Next ➡</a>
        </div>
    {{ /paginate }}

When generating a static site, the home page is displayed correctly. However, clicking the pagination links takes you to /?page=2, etc. Since there's no server running, the query param does nothing and the original home page is rendered.

Maybe there's a way the generator could parse the {{ collection:posts limit="10" paginate="true" as="posts" }} tag and somehow loop through/generate the pages?

ryangjchandler commented 4 years ago

I'd imagine it generating sub pages, possibly as /page/2 or something like that with the paginated results there.

tao commented 4 years ago

I've been spending a bit of time thinking of an elegant way to do the pagination without generating lots of static sub pages like /articles/1 /articles/2 and I've created an demo of what I think might be a nice way to support pagination with ssg if it's possible.

Here is the demo code, and it's been inspired a lot by the idea of server partials without trying to go full live-wire. So this still needs some work but I thought it would be fun to share what I'm thinking...

So in the example I've created, it loads 5 paginated articles under /articles and then uses Javascript to fetch partials when the user clicks on the pagination links below.

Screenshot from 2020-04-28 00-38-47

I've done this by creating a simple route for a server generated partial of the paginated collection

# routes/web.php
Route::statamic('/api/partials/articles, 'pagination._articles', ['layout' => null])

In this new pagination partial _articles.antlers.html I've basically just copied and pasted the pagination snippet. Some small changes you can see here is that the pagination links have a new onclick method to a Javascript function.

{{ collection:articles as="articles" limit="5" paginate="true" }}
    {{ if no_results }}
    <h2 class="text-3xl italic tracking-tight">Feel the rhythm! Feel the rhyme! Get on up, it's writing time! Cool
        writings!</h2>
    {{ /if }}

    {{ articles }}
    <div class="mb-2">
        <a href="{{ url }}" class="text-lg hover:text-teal tracking-tight leading-tight font-bold">
            &mdash; {{ title | widont }}
        </a>
    </div>
    {{ /articles }}

    {{ paginate }}
    <div class="flex justify-between text-xl -mx-16 pt-16 font-bold">
        {{ if current_page|subtract:1 > 0 }}
            <button onClick="fetchPagination({{ current_page|subtract:1 }})" class="cursor-pointer font-bold hover:text-teal-600 {{ ! prev_page ??= 'opacity-25' }}">
                &larr; Back to the future
            </button>
        {{ /if }}
        {{ if !no_results }}
            <button onClick="fetchPagination({{ current_page|add:1 }})" class="cursor-pointer font-bold hover:text-teal-600">
                Forward to the past &rarr;
            </button>
        {{ /if }}
    </div>
    {{ /paginate }}
{{ /collection:articles }}

The Javascript function is quite simple and it sits in the articles/index.antlers.html file:

<div class="max-w-5xl mx-auto relative mt-16">
    <article class="py-32 max-w-2xl mx-auto">
        <header class="content mb-16">
            <h1>{{ title }}</h1>
            {{ content | widont }}
        </header>
        <div id="js_paginate_me">
            {{ partial:pagination/articles }}
        </div>
    </article>
</div>

<script>
    function fetchPagination(page) {

        let baseUrl = '/api/partials/articles'
        let url = (page ? `${baseUrl}?page=${page}` : baseUrl)

        fetch(url)
        .then(response => response.text())
        .then(html => {
            document.querySelector('#js_paginate_me').innerHTML = html;
        })
    }

    // fetchPagination();
</script>

You can see that fetchPagination() method is commented out because it's optional, because we can just render the articles by including the partial... but the "server partials" video linked above goes into a lot more detail to explain the idea. Basically all this JS method does is fetch the pre-rendered pagination partial from our new server route and replace the old HTML with the new HTML.

On this articles page we also wrap the paginated articles in a new div tag with #js_paginate_me so our Javascript knows where to replace the html once it's fetched.

So that's pretty much it, the URL for the page /articles stays the same but we can paginate the articles collection now. The ?page={page} is the magic part that lets the antlers pagination code fetch the correct articles, so we might have to switch to using {offset} here when we statically generate all the partials.

As you can see, not that many changes are required and as far as making this completely static, I believe it shouldn't be that hard to detect and generate all the api/partials/{collection} statically and include them along with the other statically generated files... maybe in a new folder /storage/app/static/api/partials and then our Javascript can just fetch the pre-rendered pagination from /api/partials/articles_{page}.html instead of /api/partials/articles?page={page}. This part is a bit tricky but I'm sure we can find a way to get the same URLs to work so they function on the normal Statamic site and the Statically generated site.

Perhaps to get this to work our original API route can also change so it supports both:

Route::statamic('/api/partials/articles_{page}', 'pagination._articles', ['layout' => null])

So I love the benefits of this in that we get pagination without doing any fancy Javascript on the frontend; the partials code also doesn't need to change much and we don't have a bunch of strange static sub pages which make our URLs look strange.

The downside of this of course is that the pagination won't work without Javascript, so we might need to include some warning that the pagination requires Javascript to function, and the user can enable it in the browser... but what's nice is they'll still see the first 5 articles on the page even if Javascript is disabled because it's all pre-rendered.

What do you think? How difficult would it be to detect and generate all the pagination partials in the static site generation? Do you have any ideas on how we can improve this so the user doesn't have to modify the code much?

owenconti commented 4 years ago

@tao this is a crazy level of detail you have here!

If I understand correctly, the static generation process would still need to generate all of the pages, but as partials. If that's true, what's the benefit of this approach over just generating the actual pages as /articles/1, /articles/2, etc?

tao commented 4 years ago

@owenconti That's a good question. I find it just a little bit cleaner but also I want my URLs to stay the same between my Statamic site and my static site, so I can switch between them if necessary without causing any confusion, broken links or SEO problems.

I also wasn't sure if the SSG would be able to generate pagination sub-pages like that because from my understanding of browsing the code it just seems to do a HTTP request to Statamic for the rendered page and saves it... so I wasn't sure if it would be able to detect pagination tags on each page. This is just my suggestion with my limited understanding and I believe this might be an easy compromise without adding too much complexity to the SSG code.

Hopefully one of the Statamic devs with a better understanding the codebase can add their perspective on a potential solution :blush:

tao commented 4 years ago

I've made a bit more progress on this idea of mine on the frontend, but I'm getting caught up on the routes.

Route::statamic('/api/partials/news/{page}', 'news-feed._pagination', ['layout' => null]);

In the SSG config I'm doing some testing by manually adding the news pagination urls:

    'urls' => [
        '/api/partials/news/1',
        '/api/partials/news/2',
    ],

The static site generator requires the routes with the page numbers so it can generate the partials in the correct folders: e.g /app/static/api/partials/news/1/index.html. In the related issue, I mentioned that query strings can't be used in the static generation, and thus /api/partials/news?page=1 is not working correctly... however the antlers collection tag with pagination only works with the query string like page=2 at the moment. I don't think getting it to work with query strings is as important because ssg:generate won't know which folders to write the pagination partials into with query strings.

If we can get the Statamic pagination updated to allow specifying the page number in the tag then I believe we can make this type of pagination work on both the live and static websites.

For example, like in this pagination partial/tag if we could optionally set {page} manually:

{{ collection from="news" paginate="true" page="{page}" limit="15" as="entries" sort="date:desc"  }}

    {{ partial:partials/paginate/no_results }}

    <ul class="list-group">
        {{ entries }}
        <li class="list-item">
            <div class="title">
                <a href="{{ url }}" class="link">{{ title }}</a>
                <span class="meta">{{ date }}</span>
            </div>
        </li>
        {{ /entries }}
    </ul>

    {{ partial:partials/paginate/widget }}

{{ /collection }}

If we can get the pagination to work by using a route parameter like partials/news/{page} then I believe it should be easier to automatically generate all the pagination partials statically. First we could have an option in the configs which specifies the collection-name and associated view-partial, then it'll just count the total number of entries and generate the correct number of pages based on the limit.

On the frontend I've also been working on a nice fallback if the user doesn't have javascript, and it still loads the first 15 news articles without javascript so it still works quite well:

Screenshot 2020-05-18 at 01 09 09 Screenshot 2020-05-18 at 01 09 40

So it's almost there, but as the URLs are changing it's getting closer to @owenconti idea of generating the actual pages as /articles/1, /articles/2, while with this approach the url would always remain /articles on both the live and static version.

The folder output comes out like this in the end so it seems nicely organised but I also haven't considered much what'll happen if you have a collection paginated twice on the same website with different limits, and I haven't thought about how it'll work with two different collections paginated on the same page, but I do believe if we add IDs to the Javascript it should handle that fine...

/ api / partials / news / 1 / index.html
/ api / partials / news / 2 / index.html
/ news / index.html
/ news / article-1 / index.html
/ news / article-2 / index.html
/ news / article-3 / index.html
tao commented 4 years ago

I've updated my code example with an Artisan command that runs after boot in the AppServiceProvider, so that it figures out the number of pages and creates them automatically, so be sure to view the previous commit acdde51 if you'd like to see the example I mentioned above.

The new example is a bit more complicated to understand but instead of defining the api partials manually like this:

     'urls' => [
        '/api/partials/news/1',
        '/api/partials/news/2',
    ],

You can now define the partials to paginate in the config and it'll figure out the rest for you.

    'paginate' => [
        'articles' => [
            'collection' => 'articles',
            'template' => 'articles.static._pagination',
            'paginate' => 10,
        ],
    ],

However, from my experience playing with this, the static pagination gets a lot more complicated if you want to include collection filtering and sorting... so I've only done a basic example of that in the latest commit... but I'm starting to think that switching to Javascript view/template that calls the new Statamic API might be a better approach to solve this problem.

tao commented 3 years ago

I have another example of achieving static pagination using some custom tags. It works fine for this example but can become a bit complicated if you start to use filters, sorting, etc on the entries.

Screenshot 2021-01-24 at 20 41 42 Screenshot 2021-01-24 at 20 52 34 Screenshot 2021-01-24 at 20 52 37

Includes:

It works with taxonomies too but it's a bit hacky and uses slicing for the entries instead of asking for pagination & limit in the collection tag, but I've attached a zip file so you can see it works for this little example. It's a lot more complicated but I'm now using this on my project instead of the javascript based solution I suggested previously as we require 100% static pagination.

static.zip

jasonvarga commented 3 years ago

I've whipped up an example for you to get pagination working. Let me know if it works for you.

https://gist.github.com/jasonvarga/256f293f8f55bf564c907a335a2f40f3

It requires no updates to statamic/cms, statamic/ssg, or your templates.

mbootsman commented 1 year ago

I've whipped up an example for you to get pagination working. Let me know if it works for you.

https://gist.github.com/jasonvarga/256f293f8f55bf564c907a335a2f40f3

It requires no updates to statamic/cms, statamic/ssg, or your templates.

Hi @jasonvarga I think I applied everything in that gist correctly, but I can't get it to work. Can you take a look at my comment here: https://gist.github.com/jasonvarga/256f293f8f55bf564c907a335a2f40f3?permalink_comment_id=4564679#gistcomment-4564679 Thanks!