diff --git a/packages/core/src/data/strategies/AbstractFetchStrategy.ts b/packages/core/src/data/strategies/AbstractFetchStrategy.ts
index 615b67342..75805a36b 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 7d7fdb7b1..732171c11 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);
@@ -274,6 +279,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;
@@ -304,7 +310,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-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/wp/headless-wp/includes/classes/Integrations/YoastSEO.php b/wp/headless-wp/includes/classes/Integrations/YoastSEO.php
index f4bdd7dd6..f4d43762f 100644
--- a/wp/headless-wp/includes/classes/Integrations/YoastSEO.php
+++ b/wp/headless-wp/includes/classes/Integrations/YoastSEO.php
@@ -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 );
}
/**
@@ -322,4 +326,127 @@ 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 ) {
+ 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 );
+ }
}
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..3c91bad0b
--- /dev/null
+++ b/wp/headless-wp/tests/php/tests/TestYoastIntegration.php
@@ -0,0 +1,220 @@
+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->tag_id = $this->factory()->term->create(
+ [
+ 'taxonomy' => 'post_tag',
+ 'slug' => 'test-post-tag',
+ ]
+ );
+ $this->author_id = $this->factory()->user->create(
+ [
+ 'role' => 'editor',
+ 'user_login' => 'test_author',
+ 'user_pass' => 'password',
+ 'user_email' => 'testauthor@example.com',
+ 'display_name' => 'Test Author',
+ ]
+ );
+
+ $random_category_id = $this->factory()->term->create(
+ [
+ 'taxonomy' => 'category',
+ 'slug' => 'random-category',
+ ]
+ );
+ $random_tag_id = $this->factory()->term->create(
+ [
+ 'taxonomy' => 'post_tag',
+ 'slug' => 'random-post-tag',
+ ]
+ );
+
+ $post_1 = $this->factory()->post->create_and_get(
+ [
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_author' => $this->author_id,
+ ]
+ );
+
+ $post_2 = $this->factory()->post->create_and_get(
+ [
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_author' => $this->author_id,
+ ]
+ );
+
+ wp_set_post_terms( $post_1->ID, [ $this->category_id, $random_category_id ], 'category' );
+ wp_set_post_terms( $post_2->ID, [ $this->category_id, $random_category_id ], 'category' );
+ wp_set_post_terms( $post_1->ID, [ $this->tag_id, $random_tag_id ], 'post_tag' );
+ wp_set_post_terms( $post_2->ID, [ $this->tag_id, $random_tag_id ], 'post_tag' );
+ }
+
+ /**
+ * Tests optimising the Yoast SEO payload in REST API responses.
+ *
+ * @return void
+ */
+ public function test_optimise_yoast_payload() {
+
+ // Perform a REST API request for the posts by category.
+ $result_category = $this->get_posts_by_with_optimised_response( 'categories', $this->category_id );
+ $this->assert_yoast_head_in_response( $result_category );
+
+ // Perform a REST API request for the posts by tag.
+ // $result_tag = $this->get_posts_by_with_optimised_response( 'tags', $this->tag_id );
+ // $this->assert_yoast_head_in_response( $result_tag );
+
+ // Perform a REST API request for the posts by author.
+ // $result_author = $this->get_posts_by_with_optimised_response( 'author', $this->author_id );
+ // $this->assert_yoast_head_in_response( $$result_author );
+ }
+
+ /**
+ * 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 );
+ $request->set_param( 'optimizeYoastPayload', true );
+ $request->set_param( '_embed', true );
+
+ $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.' );
+ }
+ }
+ }
+ }
+}