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