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.' ); + } + } + } + } +}