diff --git a/.changeset/ninety-lemons-care.md b/.changeset/ninety-lemons-care.md
new file mode 100644
index 000000000..0f4d5a741
--- /dev/null
+++ b/.changeset/ninety-lemons-care.md
@@ -0,0 +1,6 @@
+---
+"@headstartwp/headstartwp": minor
+"@headstartwp/core": minor
+---
+
+Introducing optimizeYoastPayload to reduce payload size when using the yoast integration
diff --git a/packages/core/src/data/strategies/AbstractFetchStrategy.ts b/packages/core/src/data/strategies/AbstractFetchStrategy.ts
index 030fecf6b..61ad7b0d4 100644
--- a/packages/core/src/data/strategies/AbstractFetchStrategy.ts
+++ b/packages/core/src/data/strategies/AbstractFetchStrategy.ts
@@ -28,6 +28,13 @@ export interface EndpointParams {
*/
lang?: string;
+ /**
+ * The custom parameter to optimize the Yoast payload.
+ *
+ * This is only used if the YoastSEO integration is enabled
+ */
+ optimizeYoastPayload?: boolean;
+
[k: string]: unknown;
}
diff --git a/packages/core/src/data/strategies/AuthorArchiveFetchStrategy.ts b/packages/core/src/data/strategies/AuthorArchiveFetchStrategy.ts
index 30ab6308c..cc30a80f7 100644
--- a/packages/core/src/data/strategies/AuthorArchiveFetchStrategy.ts
+++ b/packages/core/src/data/strategies/AuthorArchiveFetchStrategy.ts
@@ -1,4 +1,4 @@
-import { getCustomTaxonomies } from '../../utils';
+import { getCustomTaxonomies, getSiteBySourceUrl } from '../../utils';
import { PostEntity } from '../types';
import { authorArchivesMatchers } from '../utils/matchers';
import { parsePath } from '../utils/parsePath';
@@ -25,6 +25,10 @@ export class AuthorArchiveFetchStrategy<
const matchers = [...authorArchivesMatchers];
const customTaxonomies = getCustomTaxonomies(this.baseURL);
+ const config = getSiteBySourceUrl(this.baseURL);
+
+ this.optimizeYoastPayload = !!config.integrations?.yoastSEO?.optimizeYoastPayload;
+
customTaxonomies?.forEach((taxonomy) => {
const slug = taxonomy?.rewrite ?? taxonomy.slug;
matchers.push({
diff --git a/packages/core/src/data/strategies/PostOrPostsFetchStrategy.ts b/packages/core/src/data/strategies/PostOrPostsFetchStrategy.ts
index ef764b482..2ef91dbdd 100644
--- a/packages/core/src/data/strategies/PostOrPostsFetchStrategy.ts
+++ b/packages/core/src/data/strategies/PostOrPostsFetchStrategy.ts
@@ -8,7 +8,7 @@ import {
} from './AbstractFetchStrategy';
import { PostParams, SinglePostFetchStrategy } from './SinglePostFetchStrategy';
import { PostsArchiveFetchStrategy, PostsArchiveParams } from './PostsArchiveFetchStrategy';
-import { FrameworkError, NotFoundError } from '../../utils';
+import { FrameworkError, NotFoundError, getSiteBySourceUrl } from '../../utils';
/**
* The params supported by {@link PostOrPostsFetchStrategy}
@@ -61,11 +61,17 @@ export class PostOrPostsFetchStrategy<
postsStrategy: PostsArchiveFetchStrategy = new PostsArchiveFetchStrategy(this.baseURL);
+ optimizeYoastPayload: boolean = false;
+
getDefaultEndpoint(): string {
return '@postOrPosts';
}
getParamsFromURL(path: string, params: Partial
= {}): Partial
{
+ const config = getSiteBySourceUrl(this.baseURL);
+
+ this.optimizeYoastPayload = !!config.integrations?.yoastSEO?.optimizeYoastPayload;
+
this.urlParams = {
single: this.postStrategy.getParamsFromURL(path, params.single),
archive: this.postsStrategy.getParamsFromURL(path, params.archive),
diff --git a/packages/core/src/data/strategies/PostsArchiveFetchStrategy.ts b/packages/core/src/data/strategies/PostsArchiveFetchStrategy.ts
index 349556e7c..d3fe24e64 100644
--- a/packages/core/src/data/strategies/PostsArchiveFetchStrategy.ts
+++ b/packages/core/src/data/strategies/PostsArchiveFetchStrategy.ts
@@ -211,6 +211,8 @@ export class PostsArchiveFetchStrategy<
locale: string = '';
+ optimizeYoastPayload: boolean = false;
+
getDefaultEndpoint(): string {
return endpoints.posts;
}
@@ -234,6 +236,8 @@ export class PostsArchiveFetchStrategy<
this.locale = config.integrations?.polylang?.enable && params.lang ? params.lang : '';
this.path = path;
+ this.optimizeYoastPayload = !!config.integrations?.yoastSEO?.optimizeYoastPayload;
+
const matchers = [...postsMatchers];
if (typeof params.taxonomy === 'string') {
@@ -457,6 +461,12 @@ export class PostsArchiveFetchStrategy<
}
}
+ if (this.optimizeYoastPayload) {
+ finalUrl = addQueryArgs(finalUrl, {
+ optimizeYoastPayload: true,
+ });
+ }
+
return super.fetcher(finalUrl, params, options);
}
diff --git a/packages/core/src/data/strategies/SearchNativeFetchStrategy.ts b/packages/core/src/data/strategies/SearchNativeFetchStrategy.ts
index 85f74e402..40ac623a5 100644
--- a/packages/core/src/data/strategies/SearchNativeFetchStrategy.ts
+++ b/packages/core/src/data/strategies/SearchNativeFetchStrategy.ts
@@ -74,6 +74,8 @@ export class SearchNativeFetchStrategy<
locale: string = '';
+ optimizeYoastPayload: boolean = false;
+
getDefaultEndpoint() {
return endpoints.search;
}
@@ -94,6 +96,8 @@ export class SearchNativeFetchStrategy<
// Required for search lang url.
this.locale = config.integrations?.polylang?.enable && params.lang ? params.lang : '';
+ this.optimizeYoastPayload = !!config.integrations?.yoastSEO?.optimizeYoastPayload;
+
return parsePath(searchMatchers, path) as Partial
;
}
@@ -165,6 +169,10 @@ export class SearchNativeFetchStrategy<
queriedObject.search.yoast_head_json = seo_json;
}
+ if (this.optimizeYoastPayload) {
+ params.optimizeYoastPayload = true;
+ }
+
const response = await super.fetcher(url, params, {
...options,
throwIfNotFound: false,
diff --git a/packages/core/src/data/strategies/SinglePostFetchStrategy.ts b/packages/core/src/data/strategies/SinglePostFetchStrategy.ts
index 7f07d5e31..d1e651015 100644
--- a/packages/core/src/data/strategies/SinglePostFetchStrategy.ts
+++ b/packages/core/src/data/strategies/SinglePostFetchStrategy.ts
@@ -5,6 +5,7 @@ import {
removeSourceUrl,
NotFoundError,
getSiteBySourceUrl,
+ addQueryArgs,
} from '../../utils';
import { PostEntity } from '../types';
import { postMatchers } from '../utils/matchers';
@@ -90,6 +91,8 @@ export class SinglePostFetchStrategy<
shouldCheckCurrentPathAgainstPostLink: boolean = true;
+ optimizeYoastPayload: boolean = false;
+
getDefaultEndpoint(): string {
return endpoints.posts;
}
@@ -108,6 +111,8 @@ export class SinglePostFetchStrategy<
this.path = nonUrlParams.fullPath ?? path;
+ this.optimizeYoastPayload = !!config.integrations?.yoastSEO?.optimizeYoastPayload;
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { year, day, month, ...params } = parsePath(postMatchers, path);
@@ -280,6 +285,7 @@ export class SinglePostFetchStrategy<
*/
async fetcher(url: string, params: P, options: Partial = {}) {
const { burstCache = false } = options;
+ let finalUrl = url;
if (params.authToken) {
options.previewToken = params.authToken;
@@ -310,7 +316,13 @@ export class SinglePostFetchStrategy<
}
try {
- const result = await super.fetcher(url, params, options);
+ if (this.optimizeYoastPayload) {
+ finalUrl = addQueryArgs(finalUrl, {
+ optimizeYoastPayload: true,
+ });
+ }
+
+ const result = await super.fetcher(finalUrl, params, options);
return result;
} catch (e) {
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index a28123522..ed2f8145c 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -48,7 +48,9 @@ export interface Integration {
enable: boolean;
}
-export interface YoastSEOIntegration extends Integration {}
+export interface YoastSEOIntegration extends Integration {
+ optimizeYoastPayload?: boolean;
+}
export interface PolylangIntegration extends Integration {}
diff --git a/projects/wp-multisite-i18n-nextjs/headstartwp.config.js b/projects/wp-multisite-i18n-nextjs/headstartwp.config.js
index 519ffa128..f14b8c69c 100644
--- a/projects/wp-multisite-i18n-nextjs/headstartwp.config.js
+++ b/projects/wp-multisite-i18n-nextjs/headstartwp.config.js
@@ -45,4 +45,11 @@ module.exports = {
useWordPressPlugin: true,
},
],
+
+ integrations: {
+ yoastSEO: {
+ enable: true,
+ optimizeYoastPayload: true,
+ },
+ },
};
diff --git a/projects/wp-multisite-nextjs-app/headstartwp.config.js b/projects/wp-multisite-nextjs-app/headstartwp.config.js
index 6565f4309..942c32a37 100644
--- a/projects/wp-multisite-nextjs-app/headstartwp.config.js
+++ b/projects/wp-multisite-nextjs-app/headstartwp.config.js
@@ -22,4 +22,10 @@ module.exports = {
hostUrl: 'http://js1.localhost:3000',
},
],
+ integrations: {
+ yoastSEO: {
+ enable: true,
+ optimizeYoastPayload: true,
+ },
+ },
};
diff --git a/projects/wp-multisite-nextjs/headstartwp.config.js b/projects/wp-multisite-nextjs/headstartwp.config.js
index 4b2a6d6ff..e5afd25be 100644
--- a/projects/wp-multisite-nextjs/headstartwp.config.js
+++ b/projects/wp-multisite-nextjs/headstartwp.config.js
@@ -32,4 +32,10 @@ module.exports = {
useWordPressPlugin: true,
},
],
+ integrations: {
+ yoastSEO: {
+ enable: true,
+ optimizeYoastPayload: true,
+ },
+ },
};
diff --git a/projects/wp-nextjs-app/headstartwp.config.js b/projects/wp-nextjs-app/headstartwp.config.js
index 2587f6bec..3881cd5f5 100644
--- a/projects/wp-nextjs-app/headstartwp.config.js
+++ b/projects/wp-nextjs-app/headstartwp.config.js
@@ -15,6 +15,7 @@ module.exports = {
integrations: {
yoastSEO: {
enable: true,
+ optimizeYoastPayload: true,
},
},
};
diff --git a/projects/wp-nextjs/headstartwp.config.client.js b/projects/wp-nextjs/headstartwp.config.client.js
index bdee699a1..823c5446d 100644
--- a/projects/wp-nextjs/headstartwp.config.client.js
+++ b/projects/wp-nextjs/headstartwp.config.client.js
@@ -41,6 +41,7 @@ module.exports = {
integrations: {
yoastSEO: {
enable: true,
+ optimizeYoastPayload: true,
},
polylang: {
enable: process?.env?.NEXT_PUBLIC_ENABLE_POLYLANG_INTEGRATION === 'true',
diff --git a/projects/wp-polylang-nextjs-app/headstartwp.config.js b/projects/wp-polylang-nextjs-app/headstartwp.config.js
index 3a23b5906..4484d713b 100644
--- a/projects/wp-polylang-nextjs-app/headstartwp.config.js
+++ b/projects/wp-polylang-nextjs-app/headstartwp.config.js
@@ -18,5 +18,9 @@ module.exports = {
polylang: {
enable: true,
},
+ yoastSEO: {
+ enable: true,
+ optimizeYoastPayload: true,
+ },
},
};
diff --git a/test-projects/wp-nextjs-universal-blocks/headstartwp.config.js b/test-projects/wp-nextjs-universal-blocks/headstartwp.config.js
index 2587f6bec..3881cd5f5 100644
--- a/test-projects/wp-nextjs-universal-blocks/headstartwp.config.js
+++ b/test-projects/wp-nextjs-universal-blocks/headstartwp.config.js
@@ -15,6 +15,7 @@ module.exports = {
integrations: {
yoastSEO: {
enable: true,
+ optimizeYoastPayload: true,
},
},
};
diff --git a/wp/headless-wp/includes/classes/Integrations/YoastSEO.php b/wp/headless-wp/includes/classes/Integrations/YoastSEO.php
index f4bdd7dd6..6d9acd9ab 100644
--- a/wp/headless-wp/includes/classes/Integrations/YoastSEO.php
+++ b/wp/headless-wp/includes/classes/Integrations/YoastSEO.php
@@ -36,6 +36,13 @@ 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 );
+
+ // Filter 'up' link embeddable property to prevent parent pages from being embedded with yoast_head
+ add_action( 'rest_api_init', [ $this, 'filter_up_link_embeddable' ] );
}
/**
@@ -322,4 +329,182 @@ 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.
+ * @param boolean $embed Whether the response should include embedded data.
+ *
+ * @return array Modified response data.
+ */
+ public function optimise_yoast_payload( $result, $server, $request, $embed = false ) {
+
+ $embed = $embed ? $embed : filter_var( wp_unslash( $_GET['_embed'] ?? false ), FILTER_VALIDATE_BOOLEAN );
+
+ if ( ! $embed || empty( $request->get_param( 'optimizeYoastPayload' ) ) ) {
+ 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 && empty( $request->get_param( 'slug' ) ) ) {
+ 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 = 'category' === $term_obj['taxonomy'] ?
+ $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 );
+ }
+
+ /**
+ * Registers filters to disable embeddable property for 'up' links on hierarchical post types
+ *
+ * This prevents parent pages from being embedded with their yoast_head data,
+ * reducing payload size.
+ */
+ public function filter_up_link_embeddable() {
+ $post_types = get_post_types(
+ [
+ 'show_in_rest' => true,
+ 'hierarchical' => true,
+ ],
+ 'names'
+ );
+
+ foreach ( $post_types as $post_type ) {
+ add_filter( "rest_prepare_{$post_type}", [ $this, 'disable_up_link_embeddable' ], 10, 3 );
+ }
+ }
+
+ /**
+ * Disables embeddable property for 'up' link in REST API response
+ *
+ * @param \WP_REST_Response $response The response object.
+ * @param \WP_Post $post Post object.
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @return \WP_REST_Response
+ */
+ public function disable_up_link_embeddable( $response, $post, $request ) {
+ // Only modify if optimizeYoastPayload parameter is present
+ if ( empty( $request->get_param( 'optimizeYoastPayload' ) ) ) {
+ return $response;
+ }
+
+ // Get the links from the response object
+ $links = $response->get_links();
+
+ // Check if 'up' link exists
+ if ( ! empty( $links['up'] ) ) {
+ // Remove existing 'up' links
+ $response->remove_link( 'up' );
+
+ // Re-add each 'up' link with embeddable set to false
+ foreach ( $links['up'] as $up_link ) {
+ $href = $up_link['href'];
+
+ // Try passing embeddable as a direct attribute
+ $response->add_link( 'up', $href, [ 'embeddable' => false ] );
+ }
+ }
+
+ return $response;
+ }
}
diff --git a/wp/headless-wp/tests/php/tests/TestYoastIntegration.php b/wp/headless-wp/tests/php/tests/TestYoastIntegration.php
new file mode 100644
index 000000000..c04b0226e
--- /dev/null
+++ b/wp/headless-wp/tests/php/tests/TestYoastIntegration.php
@@ -0,0 +1,336 @@
+yoast_seo = new YoastSEO();
+ $this->yoast_seo->register();
+ self::$rest_server = rest_get_server();
+
+ $this->test_data = [];
+ $this->set_test_data();
+ }
+
+ /**
+ * Set test data for all scenarios.
+ * Using string IDs to match real REST API responses.
+ */
+ protected function set_test_data() {
+ $this->test_data = [
+ 'jane_author' => '6',
+ 'other_author' => '7',
+ 'news_category' => '8',
+ 'other_category' => '9',
+ 'post_1' => '29',
+ 'post_2' => '30',
+ 'post_3' => '31',
+ ];
+ }
+
+ /**
+ * Manually construct REST API response format for testing Yoast optimization
+ *
+ * @return array
+ */
+ protected function create_manual_rest_api_data() {
+ return [
+ [
+ 'id' => $this->test_data['post_1'],
+ 'title' => [ 'rendered' => 'First Post by Jane' ],
+ 'author' => $this->test_data['jane_author'],
+ 'yoast_head' => 'First Post by Jane',
+ 'yoast_head_json' => [ 'title' => 'First Post by Jane' ],
+ '_embedded' => [
+ 'author' => [
+ [
+ 'id' => $this->test_data['jane_author'],
+ 'name' => 'Jane Author',
+ 'slug' => 'jane-author',
+ 'yoast_head' => 'Jane Author',
+ 'yoast_head_json' => [ 'title' => 'Jane Author' ],
+ ],
+ ],
+ 'wp:term' => [
+ [
+ [
+ 'id' => $this->test_data['news_category'],
+ 'name' => 'News Category',
+ 'slug' => 'news-category',
+ 'taxonomy' => 'category',
+ 'yoast_head' => 'News Category',
+ 'yoast_head_json' => [ 'title' => 'News Category' ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ [
+ 'id' => $this->test_data['post_2'],
+ 'title' => [ 'rendered' => 'Second Post by Jane' ],
+ 'author' => $this->test_data['jane_author'],
+ 'yoast_head' => 'Second Post by Jane',
+ 'yoast_head_json' => [ 'title' => 'Second Post by Jane' ],
+ '_embedded' => [
+ 'author' => [
+ [
+ 'id' => $this->test_data['jane_author'],
+ 'name' => 'Jane Author',
+ 'slug' => 'jane-author',
+ 'yoast_head' => 'Jane Author',
+ 'yoast_head_json' => [ 'title' => 'Jane Author' ],
+ ],
+ ],
+ 'wp:term' => [
+ [
+ [
+ 'id' => $this->test_data['news_category'],
+ 'name' => 'News Category',
+ 'slug' => 'news-category',
+ 'taxonomy' => 'category',
+ 'yoast_head' => 'News Category',
+ 'yoast_head_json' => [ 'title' => 'News Category' ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Single post query
+ * Only the queried post should have yoast metadata
+ */
+ public function test_single_post_query() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . $this->test_data['post_1'] );
+ $request->set_param( 'optimizeYoastPayload', true );
+ $request->set_param( '_embed', true );
+
+ $manual_data = $this->create_manual_rest_api_data();
+ $data_for_optimization = [ $manual_data[0] ];
+
+ $optimized_data = $this->yoast_seo->optimise_yoast_payload( $data_for_optimization, self::$rest_server, $request, true );
+ $optimized_post = $optimized_data[0];
+
+ // The single post should have yoast_head
+ $this->assertArrayHasKey( 'yoast_head', $optimized_post, 'Single queried post should have yoast_head' );
+
+ // Embedded terms and authors should NOT have yoast_head since no specific term/author was queried
+ if ( isset( $optimized_post['_embedded'] ) ) {
+ $this->assert_no_yoast_in_embedded( $optimized_post['_embedded'], 'No embedded items should have yoast_head for single post query' );
+ }
+ }
+
+ /**
+ * Posts by category
+ * Only first post and queried category should have yoast metadata
+ */
+ public function test_posts_by_category() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
+ $request->set_param( 'categories', $this->test_data['news_category'] );
+ $request->set_param( 'per_page', 10 );
+ $request->set_param( 'optimizeYoastPayload', true );
+ $request->set_param( '_embed', true );
+
+ $data = $this->create_manual_rest_api_data();
+ $optimized_data = $this->yoast_seo->optimise_yoast_payload( $data, self::$rest_server, $request, true );
+
+ $this->assertGreaterThanOrEqual( 2, count( $optimized_data ), 'Should return at least 2 posts' );
+
+ // First post should have yoast_head
+ $this->assertArrayHasKey( 'yoast_head', $optimized_data[0], 'First post should have yoast_head' );
+
+ // Subsequent posts should NOT have yoast_head
+ $post_count = count( $optimized_data );
+ for ( $i = 1; $i < $post_count; $i++ ) {
+ $this->assertArrayNotHasKey( 'yoast_head', $optimized_data[ $i ], "Post {$i} should not have yoast_head" );
+ }
+
+ // Check embedded terms - only the queried category should have yoast_head
+ if ( isset( $optimized_data[0]['_embedded']['wp:term'] ) ) {
+ $this->assert_yoast_in_term( $optimized_data[0]['_embedded']['wp:term'], $this->test_data['news_category'], 'category' );
+ }
+ }
+
+ /**
+ * Posts by author
+ * Only first post and queried author should have yoast metadata
+ */
+ public function test_posts_by_author() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
+ $request->set_param( 'author', $this->test_data['jane_author'] );
+ $request->set_param( 'per_page', 10 );
+ $request->set_param( 'optimizeYoastPayload', true );
+ $request->set_param( '_embed', true );
+
+ $data = $this->create_manual_rest_api_data();
+ $optimized_data = $this->yoast_seo->optimise_yoast_payload( $data, self::$rest_server, $request, true );
+
+ $this->assertGreaterThanOrEqual( 2, count( $optimized_data ), 'Should return at least 2 posts' );
+
+ // First post should have yoast_head
+ $this->assertArrayHasKey( 'yoast_head', $optimized_data[0], 'First post should have yoast_head' );
+
+ // Subsequent posts should NOT have yoast_head
+ $post_count = count( $optimized_data );
+ for ( $i = 1; $i < $post_count; $i++ ) {
+ $this->assertArrayNotHasKey( 'yoast_head', $optimized_data[ $i ], "Post {$i} should not have yoast_head" );
+ }
+
+ // Check embedded authors - only the queried author should have yoast_head
+ if ( isset( $optimized_data[0]['_embedded']['author'] ) ) {
+ $author = $optimized_data[0]['_embedded']['author'][0];
+ if ( $author['id'] === $this->test_data['jane_author'] ) {
+ $this->assertArrayHasKey( 'yoast_head', $author, 'Queried author should have yoast_head' );
+ } else {
+ $this->assertArrayNotHasKey( 'yoast_head', $author, 'Non-queried author should not have yoast_head' );
+ }
+ }
+ }
+
+ /**
+ * Posts by category and author
+ * Only first post, queried category, and queried author should have yoast metadata
+ */
+ public function test_posts_by_category_and_author() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
+ $request->set_param( 'categories', $this->test_data['news_category'] );
+ $request->set_param( 'author', $this->test_data['jane_author'] );
+ $request->set_param( 'per_page', 10 );
+ $request->set_param( 'optimizeYoastPayload', true );
+ $request->set_param( '_embed', true );
+
+ $data = $this->create_manual_rest_api_data();
+ $optimized_data = $this->yoast_seo->optimise_yoast_payload( $data, self::$rest_server, $request, true );
+
+ $this->assertGreaterThanOrEqual( 1, count( $optimized_data ), 'Should return at least 1 post' );
+
+ // First post should have yoast_head
+ $this->assertArrayHasKey( 'yoast_head', $optimized_data[0], 'First post should have yoast_head' );
+
+ // Subsequent posts should NOT have yoast_head
+ $post_count = count( $optimized_data );
+ for ( $i = 1; $i < $post_count; $i++ ) {
+ $this->assertArrayNotHasKey( 'yoast_head', $optimized_data[ $i ], "Post {$i} should not have yoast_head" );
+ }
+
+ // Check embedded terms - only the queried category should have yoast_head
+ if ( isset( $optimized_data[0]['_embedded']['wp:term'] ) ) {
+ $this->assert_yoast_in_term( $optimized_data[0]['_embedded']['wp:term'], $this->test_data['news_category'], 'category' );
+ }
+
+ // Check embedded authors - only the queried author should have yoast_head
+ if ( isset( $optimized_data[0]['_embedded']['author'] ) ) {
+ $author = $optimized_data[0]['_embedded']['author'][0];
+ if ( $author['id'] === $this->test_data['jane_author'] ) {
+ $this->assertArrayHasKey( 'yoast_head', $author, 'Queried author should have yoast_head' );
+ } else {
+ $this->assertArrayNotHasKey( 'yoast_head', $author, 'Non-queried author should not have yoast_head' );
+ }
+ }
+ }
+
+ /**
+ * Test that optimization only runs when optimizeYoastPayload parameter is true
+ */
+ public function test_optimization_only_runs_when_parameter_is_set() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
+ $request->set_param( 'categories', $this->test_data['news_category'] );
+ $request->set_param( 'per_page', 10 );
+ $request->set_param( '_embed', true );
+
+ $data = $this->create_manual_rest_api_data();
+ $optimized_data = $this->yoast_seo->optimise_yoast_payload( $data, self::$rest_server, $request, true );
+
+ $this->assertEquals( $data, $optimized_data, 'Data should remain unchanged when optimizeYoastPayload is not set' );
+ }
+
+ /**
+ * Helper method to assert no yoast_head in any embedded items
+ *
+ * @param array $embedded The embedded data
+ * @param string $message The assertion message
+ */
+ protected function assert_no_yoast_in_embedded( $embedded, $message ) {
+ foreach ( $embedded as $embed_type => $embed_data ) {
+ if ( is_array( $embed_data ) ) {
+ foreach ( $embed_data as $item_group ) {
+ $items = is_array( $item_group ) && isset( $item_group[0] ) ? $item_group : [ $item_group ];
+ foreach ( $items as $item ) {
+ if ( is_array( $item ) ) {
+ $this->assertArrayNotHasKey( 'yoast_head', $item, $message . " (in {$embed_type})" );
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper method to assert yoast_head exists only in specific term
+ *
+ * @param array $terms The terms data
+ * @param int $target_id The ID of the term that should have yoast_head
+ * @param string $taxonomy The taxonomy name
+ */
+ protected function assert_yoast_in_term( $terms, $target_id, $taxonomy ) {
+ foreach ( $terms as $term_group ) {
+ if ( is_array( $term_group ) ) {
+ foreach ( $term_group as $term ) {
+ if ( isset( $term['id'] ) && isset( $term['taxonomy'] ) ) {
+ if ( $term['id'] === $target_id && $term['taxonomy'] === $taxonomy ) {
+ $this->assertArrayHasKey( 'yoast_head', $term, "Queried {$taxonomy} should have yoast_head" );
+ } else {
+ $this->assertArrayNotHasKey( 'yoast_head', $term, "Non-queried {$taxonomy} should not have yoast_head" );
+ }
+ }
+ }
+ }
+ }
+ }
+}