Previous and Next Post Links (take two)

❗ This is not a tutorial ❗

In https://zalgorithm.com/link-to-the-previous-and-next-posts/ I added “previous” and “next” navigation links to the bottom of each post. It worked, but I haven’t pushed the change to the live site. My concern is that the loader function on blog.$slug.tsx is now making three requests – first it pulls in a post based on the URL’s slug param, then it makes two requests to get data for the previous and next posts. This seems inefficient.

A possible workaround is to add a plugin to the WordPress site that extends the WPGraphQL plugin to return previousPost and nextPost data.

I made a start on that yesterday, but stopped when I realized I didn’t understand what I was doing. I’ll have another look at it.

Extending the WPGraphQL plugin

I’ll start by going through this tutorial: https://www.wpgraphql.com/docs/build-your-first-wpgraphql-extension.

… that got me somewhere:

// wpgraphql-previous-next.php

<?php
/**
 * Plugin Name: WPGraphQL Previous Next
 * Description: Adds previousPost and nextPost fields to the data returned by the WPGraphQL plugin
 * Version: 0.1
 * Author: scossar
 */

add_action( 'graphql_register_types', 'add_previous_and_next_post_to_wpgraphql_schema' );

function add_previous_and_next_post_to_wpgraphql_schema() {
	register_graphql_field( 'Post', 'previousPost', [
		'type' => 'String',
		'description' => 'Once implemented, will return the previousPost from a Post query',
		'resolve' => function() {
		return 'the previousPost field is returning a string for now, later it will return a post';
		}
	]);
}

Going back to the example code I was looking at yesterday (https://github.com/kellenmace/pagination-station/blob/main/pagination-fields.php), this statement is making more sense:

use WPGraphQL\Model\Post;

WPGraphQL\Model\Post is the type that the previousPost and nextPost fields are being added to. The code is here: https://github.com/wp-graphql/wp-graphql/blob/develop/src/Model/Post.php.

These are the WPGraphQL\Model\Post fields:

// wp-graphql/src/Model/Post.php

...
/**
 * Class Post - Models data for the Post object type
 *
 * @property int     $ID
 * @property string  $post_author
 * @property string  $id
 * @property string  $post_type
 * @property string  $authorId
 * @property string  $authorDatabaseId
 * @property int     $databaseId
 * @property string  $date
 * @property string  $dateGmt
 * @property string  $contentRendered
 * @property string  $contentRaw
 * @property string  $titleRendered
 * @property string  $titleRaw
 * @property string  $excerptRendered
 * @property string  $excerptRaw
 * @property string  $post_status
 * @property string  $status
 * @property string  $commentStatus
 * @property string  $pingStatus
 * @property string  $slug
 * @property array   $template
 * @property bool $isFrontPage
 * @property bool $isPrivacyPage
 * @property bool $isPostsPage
 * @property bool $isPreview
 * @property bool $isRevision
 * @property bool $isSticky
 * @property string  $toPing
 * @property string  $pinged
 * @property string  $modified
 * @property string  $modifiedGmt
 * @property string  $parentId
 * @property int     $parentDatabaseId
 * @property int     $editLastId
 * @property array   $editLock
 * @property string  $enclosure
 * @property string  $guid
 * @property int     $menuOrder
 * @property string  $link
 * @property string  $uri
 * @property int     $commentCount
 * @property string  $featuredImageId
 * @property int     $featuredImageDatabaseId
 * @property string  $pageTemplate
 * @property int     $previewRevisionDatabaseId
 *
 * @property string  $captionRaw
 * @property string  $captionRendered
 * @property string  $altText
 * @property string  $descriptionRaw
 * @property string  $descriptionRendered
 * @property string  $mediaType
 * @property string  $sourceUrl
 * @property string  $mimeType
 * @property array   $mediaDetails
 *
 * @package WPGraphQL\Model
 */
...

This statement in the example code was also confusing:

use WP_Post;

It was causing my IDE to generate the warning The use statement with non-compound name 'WP_Post' has no effect. I’m guessing that’s because my file wasn’t namespaced.

I’ll go through the example code from top to bottom:

'resolve' => function( Post $resolving_post )

The argument that’s passed to the anonymous 'resolve' function is Post $resolving_post. Post is indicating the type of the argument. It’s not a WordPress post, it’s a WPGraphQL\Model\Post.

if ( is_post_type_hierarchical( $resolving_post->post_type ) )

This condition is checking to see if the WPGraphQL\Model\Post that’s being passed to the function is a hierarchical post, for example, a “page” or a hierarchical custom post type.

If it is a hierarchical (try typing that 10 times) post, it’s passed as an argument to:

private function get_previous_page_id( Post $page ): int {
    return get_adjacent_page_id( $page, -1 );
}

Note that the type of argument is again set to WPGraphQL\Model\Post. That function calls get_adjacent_page_id with the argument set to the type WP_Post ???:

private function get_adjacent_page_id( WP_Post $page, int $direction ): int {
        $args = [
            'post_type'      => $page->post_type,
            'order'          => 'ASC',
            'orderby'        => 'menu_order',
            'post_parent'    => $page->post_parent,
            'fields'         => 'ids',
            'posts_per_page' => -1
        ];

        $pages = get_posts( $args );
        $current_key = array_search( $page->ID, $pages );
        $does_adjacent_page_exist = isset( $pages[ $current_key + $direction ] );

        if ( $does_adjacent_page_exist ) {
            return $pages[ $current_key + $direction ];
        }

        return 0;
    }
}

get_adjacent_page_id is back in familiar territory. It makes a call to the WordPress get_posts function with the $args array. The 'post_parent' argument will limit the results to just the siblings of the post that’s been passed to the function. The 'fields' argument is telling get_posts to just return 'ids'. The 'posts_per_page'argument of -1 is used to tell the query to return all of the siblings of the post.

Non-hierarchical posts are handle by the part of the 'resolve' function that’s after the initial condition:

$post = get_post( $resolving_post->postId );
$GLOBALS['post'] = $post;
setup_postdata( $post );
$next_post = get_next_post();
wp_reset_postdata();

return $next_post ? new Post( $next_post ) : null;

Going through it line by line:

$post = get_post( $resolving_post->postId );

That’s just a call to the WordPress get_post function.

$GLOBALS['post'] = $post;

Sets $post as the current global post. This is needed for the next two lines:

setup_postdata( $post );
$next_post = get_next_post();

setup_postdata seems to be needed so that get_next_post can be called on the post that’s been passed to the 'resolve' function. The documentation isn’t clear on that – I thought it was only needed for setting the post for template tags, so I’m not sure.

get_next_post is a WordPress function that returns the next post that’s adjacent to the current post.

wp_reset_postdata is a WordPress function that’s used to restore the global $post object to its original state.

I feel confident enough to create a plugin based on the code that’s here: https://github.com/kellenmace/pagination-station/blob/main/pagination-fields.php.


… hmm… I’m still stumped by something in that code:

$previous_post_id = get_previous_page_id( $resolving_post );
return $previous_post_id ? new Post( $previous_post_id ) : null;

The problem is that the constructor for WPGraphQL\Model\Post is expecting an argument of the type WP_Post, not an int.

I think the code should be:

$previous_post_id = get_previous_page_id( $resolving_post );
$previous_post = get_post( $previous_post_id);
return $previous_post ? new WPGraphQL\Model\Post( $previous_post ) : null;

…but I don’t need the previous or next fields for hierarchical posts. I can simplify the code by just returning null from that condition:

<?php
/**
 * Plugin Name: WPGraphQL Adjacent Posts
 * Description: Extends the WPGraphQL plugin to add fields for adjacent posts.
 * Version: 0.1
 * Author: scossar
 * Plugin URI: https://github.com/scossar/wp-graphql-adjacent-posts
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

add_action( 'graphql_register_types', 'add_adjacent_post_fields_to_wpgraphql_schema' );

/**
 * Registers adjacent post fields.
 *
 * @return void
 */
function add_adjacent_post_fields_to_wpgraphql_schema() {
	register_graphql_fields( 'Post', [
		'previousPost'           => [
			'type'        => 'Post',
			'description' => __( 'Previous post', 'zalgorithm' ),
			'resolve'     => function ( WPGraphQL\Model\Post $resolving_post ) {
				return resolve_adjacent_post( $resolving_post, 'previous' );
			}
		],
		'nextPost'               => [
			'type'        => 'Post',
			'description' => __( 'Next post', 'zalgorithm' ),
			'resolve'     => function ( WPGraphQL\Model\Post $resolving_post ) {
				return resolve_adjacent_post( $resolving_post, 'next' );
			}
		],
		'previousPostInCategory' => [
			'type'        => 'Post',
			'description' => __( 'Previous post in same category', 'zalgorithm' ),
			'resolve'     => function ( WPGraphQL\Model\Post $resolving_post ) {
				return resolve_adjacent_post( $resolving_post, 'previous', true );
			}
		],
		'nextPostInCategory'     => [
			'type'        => 'Post',
			'description' => __( 'Next post in same category', 'zalgorithm' ),
			'resolve'     => function ( WPGraphQL\Model\Post $resolving_post ) {
				return resolve_adjacent_post( $resolving_post, 'next', true );
			}
		]
	] );
}

/**
 * Returns the next or previous post. Returns null if no post is found.
 *
 * @param \WPGraphQL\Model\Post $resolving_post The post to find the adjacent post for.
 * @param string $direction Whether to return the 'next' or 'previous' post.
 * @param boolean $in_same_term Whether to limit the next post to the post's category.
 *
 * @return \WPGraphQL\Model\Post|null
 */
function resolve_adjacent_post( WPGraphQL\Model\Post $resolving_post, $direction, $in_same_term = false ) {
	// Currently not setup to handle hierarchical posts.
	if ( is_post_type_hierarchical( $resolving_post->post_type ) ) {
		return null;
	}

	$current_post    = get_post( $resolving_post->postId );
	$GLOBALS['post'] = $current_post;
	setup_postdata( $current_post );

	// Any string other than 'next' will result in querying for the previous post.
	$adjacent_post = $direction === 'next' ? get_next_post( $in_same_term ) : get_previous_post( $in_same_term );
	wp_reset_postdata();

	return $adjacent_post ? new WPGraphQL\Model\Post( $adjacent_post ) : null;
}

Plugin repo: https://github.com/scossar/wp-graphql-adjacent-posts.

This is great! It will be simple to use these fields to add navigation to the bottom of each post. I’ll put that off until tomorrow.