Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/yoast payload optimize #727

Draft
wants to merge 10 commits into
base: develop
Choose a base branch
from
126 changes: 126 additions & 0 deletions wp/headless-wp/includes/classes/Integrations/YoastSEO.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ public function register() {

// Introduce hereflangs presenter to Yoast list of presenters.
add_action( 'rest_api_init', [ $this, 'wpseo_rest_api_hreflang_presenter' ], 10, 0 );

// Modify API response to optimise payload by removing the yoast_head and yoast_json_head where not needed.
// Embedded data is not added yet on rest_prepare_{$this->post_type}.
add_filter( 'rest_pre_echo_response' , [ $this, 'optimise_yoast_payload' ], 10, 3 );
}

/**
Expand Down Expand Up @@ -322,4 +326,126 @@ function ( $presenters ) {
}
);
}

/**
* Optimises the Yoast SEO payload in REST API responses.
*
* This method modifies the API response to reduce the payload size by removing
* the 'yoast_head' and 'yoast_json_head' fields from the response when they are
* not needed for the nextjs app.
* See https://github.com/10up/headstartwp/issues/563
*
* @param array $result The response data to be served, typically an array.
* @param \WP_REST_Server $server Server instance.
* @param \WP_REST_Request $request Request used to generate the response.
*
* @return array Modified response data.
*/
public function optimise_yoast_payload( $result, $server, $request, $embed = false ) {

$embed = $embed ?: rest_parse_embed_param( $_GET['_embed'] ?? false );
lucymtc marked this conversation as resolved.
Show resolved Hide resolved

if ( ! $embed ) {
return $result;
}

$first_post = true;

foreach ( $result as &$post_obj ) {

if ( ! empty( $post_obj['_embedded']['wp:term'] ) ) {
$this->optimise_yoast_payload_for_taxonomy( $post_obj['_embedded']['wp:term'], $request, $first_post );
}

if ( ! empty( $post_obj['_embedded']['author'] ) ) {
$this->optimise_yoast_payload_for_author( $post_obj['_embedded']['author'], $request, $first_post );
}

if ( ! $first_post ) {
unset( $post_obj['yoast_head'], $post_obj['yoast_head_json'] );
}

$first_post = false;
}

unset( $post_obj );

return $result;
}

/**
* Optimises the Yoast SEO payload for taxonomies.
* Removes yoast head from _embed terms for any term that is not in the queried params.
* Logic runs for the first post, yoast head metadata is removed completely for other posts.
*
* @param array $taxonomy_groups The _embedded wp:term collections.
* @param \WP_REST_Request $request Request used to generate the response.
* @param boolean $first_post Whether this is the first post in the response.
*
* @return void
*/
protected function optimise_yoast_payload_for_taxonomy( &$taxonomy_groups, $request, $first_post ) {

foreach ( $taxonomy_groups as &$taxonomy_group ) {

foreach ( $taxonomy_group as &$term_obj ) {

$param = null;

if ( $first_post ) {
// Get the queried terms for the taxonomy.
$param = $term_obj['taxonomy'] === 'category' ?
$request->get_param('category') ?? $request->get_param('categories') :
$request->get_param( $term_obj['taxonomy'] );
}

if ( $first_post && ! empty( $param ) ) {
$param = is_array( $param ) ? $param : explode( ',', $param );

// If the term slug is not in param array, unset yoast heads.
if ( ! in_array( $term_obj['slug'], $param, true ) && ! in_array( $term_obj['id'], $param, true ) ) {
unset( $term_obj['yoast_head'], $term_obj['yoast_head_json'] );
}
} else {
unset( $term_obj['yoast_head'], $term_obj['yoast_head_json'] );
}
}

unset( $term_obj );
}

unset( $taxonomy_group );
}

/**
* Optimises the Yoast SEO payload for author.
* Removes yoast head from _embed author for any author that is not in the queried params.
* Logic runs for the first post, yoast head metadata is removed completely for other posts.
*
* @param array $authors The _embedded author collections.
* @param \WP_REST_Request $request Request used to generate the response.
* @param boolean $first_post Whether this is the first post in the response.
*
* @return void
*/
protected function optimise_yoast_payload_for_author( &$authors, $request, $first_post ) {

foreach ( $authors as &$author ) {

$param = $first_post ? $request->get_param( 'author' ) : null;

if ( $first_post && ! empty( $param ) ) {
$param = is_array( $param ) ? $param : explode( ',', $param );

// If the term slug is not in param array, unset yoast heads.
if ( ! in_array( $author['slug'], $param, true ) && ! in_array( $author['id'], $param, true ) ) {
unset( $author['yoast_head'], $author['yoast_head_json'] );
}
} else {
unset( $author['yoast_head'], $author['yoast_head_json'] );
}
}

unset( $author );
}
}
170 changes: 170 additions & 0 deletions wp/headless-wp/tests/php/tests/TestYoastIntegration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php
/**
* Tests covering the Yoast integration
*
* @package HeadlessWP
*/

namespace HeadlessWP\Tests;

use HeadlessWP\Integrations\YoastSEO;
use WP_Test_REST_TestCase;
use WP_REST_Request;
use WP_REST_Server;

/**
* Covers the test for the Yoast integration
*/
class TestYoastIntegration extends WP_Test_REST_TestCase {
/**
* The YoastSEO instance
*
* @var YoastSEO
*/
protected $yoast_seo;

/**
* The rest server
*
* @var WP_REST_Server
*/
protected static $rest_server;

/**
* The category id
*
* @var int
*/
protected $category_id;

/**
* The author id
*
* @var int
*/
protected $author_id;

/**
* Sets up the Test class
*
* @return void
*/
public function set_up() {
parent::set_up();
$this->yoast_seo = new YoastSEO();
$this->yoast_seo->register();
self::$rest_server = rest_get_server();

$this->create_posts();
}

/**
* Create posts for testing
*/
protected function create_posts() {
$this->category_id = $this->factory()->term->create( [ 'taxonomy' => 'category', 'slug' => 'test-category' ] );
$this->author_id = $this->factory()->user->create(
[
'role' => 'editor',
'user_login' => 'test_author',
'user_pass' => 'password',
'user_email' => '[email protected]',
'display_name' => 'Test Author',
]
);

$random_category_id = $this->factory()->term->create( [ 'taxonomy' => 'category', 'slug' => 'random-category' ] );

$this->factory()->post->create_many( 2, [
'post_type' => 'post',
'post_status' => 'publish',
'post_category' => [ $this->category_id, $random_category_id ],
'post_author' => $this->author_id,
]);
}

/**
* Tests optimising the Yoast SEO payload in REST API responses.
*
* @return void
*/
public function test_optimise_category_yoast_payload() {

// Perform a REST API request for the posts by category.
$result = $this->get_posts_by_with_optimised_response( 'categories', $this->category_id );
$this->assert_yoast_head_in_response( $result );

// Perform a REST API request for the posts by author.
// $result = $this->get_posts_by_with_optimised_response( 'author', $this->author_id );
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nicholasio Not sure why this request returns empty data although posts are created with the correct author id

// $this->assert_yoast_head_in_response( $result );
}

/**
* Get the optimised response from headstartwp Yoast integration by param. (category, author)
*
* @param string $param The param to filter by (category, author)
* @param int|string $value The value of the param
*
* @return \WP_REST_Response
*/
protected function get_posts_by_with_optimised_response( $param, $value ) {

$request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
$request->set_param( $param, $value );

$response = rest_do_request( $request );
$data = self::$rest_server->response_to_data( $response, true );

$this->assertGreaterThanOrEqual( 2, count( $data ), 'There should be at least two posts returned.' );

return $this->yoast_seo->optimise_yoast_payload( $data, self::$rest_server, $request, true );
}

/**
* Asserts the presence of yoast_head in the response for each post.
*
* @param array $result The response data containing posts.
* @return void
*/
protected function assert_yoast_head_in_response( $result ) {
$first_post = true;

foreach ( $result as $post ) {

$this->assertArrayHasKey( '_embedded', $post, 'The _embedded key should exist in the response.' );
$this->assertArrayHasKey( 'wp:term', $post['_embedded'], 'The wp:term in _embedded key should exist in the response.' );
$this->assertArrayHasKey( 'author', $post['_embedded'], 'The author in _embedded key should exist in the response.' );

$this->assert_embedded_item( $post['_embedded'], 'wp:term', $first_post, $this->category_id );
$this->assert_embedded_item( $post['_embedded'], 'author', $first_post, null );

$first_post = false;
}
}

/**
* Asserts the presence of yoast_head of the expected embedded item in the response.
*
* @param array $embedded_obj The embedded object containing the items.
* @param string $type The type of embedded item to check.
* @param bool $first_post Whether it is the first post in the response.
* @param int $id The ID of the item to check
* @return void
*/
protected function assert_embedded_item( $embedded_obj, $type, $first_post, $id = null ) {

foreach ( $embedded_obj[ $type ] as $group) {

$items = 'wp:term' !== $type ? [ $group ] : $group;

foreach ( $items as $item ) {

if ( $first_post && $item['id'] === $id ) {
$this->assertArrayHasKey( 'yoast_head', $item, 'The requested ' . $type . ' should have yoast_head in the response for the first post.' );
} else {
$this->assertArrayNotHasKey( 'yoast_head', $item, 'yoast_head in ' . $type . ' should not be present for posts other than the first post and if not requested.' );
}
}
}
}
}
Loading