PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.5
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.5
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
← All changes | src/builders/indexable-link-builder.php +211 -78 18.428.5 View file →
@@ -1,11 +1,15 @@
1 1 <?php
2 2
3 3 namespace Yoast\WP\SEO\Builders;
4 4
5 +use WPSEO_Image_Utils;
5 6 use Yoast\WP\SEO\Helpers\Image_Helper;
7 +use Yoast\WP\SEO\Helpers\Indexable_Helper;
8 +use Yoast\WP\SEO\Helpers\Options_Helper;
6 9 use Yoast\WP\SEO\Helpers\Post_Helper;
7 10 use Yoast\WP\SEO\Helpers\Url_Helper;
11 +use Yoast\WP\SEO\Images\Application\Image_Content_Extractor;
8 12 use Yoast\WP\SEO\Models\Indexable;
9 13 use Yoast\WP\SEO\Models\SEO_Links;
10 14 use Yoast\WP\SEO\Repositories\Indexable_Repository;
11 15 use Yoast\WP\SEO\Repositories\SEO_Links_Repository;
@@ -36,8 +40,15 @@
36 40 */
37 41 protected $image_helper;
38 42
39 43 /**
44 + * The indexable helper.
45 + *
46 + * @var Indexable_Helper
47 + */
48 + protected $indexable_helper;
49 +
50 + /**
40 51 * The post helper.
41 52 *
42 53 * @var Post_Helper
43 54 */
@@ -43,8 +54,15 @@
43 54 */
44 55 protected $post_helper;
45 56
46 57 /**
58 + * The options helper.
59 + *
60 + * @var Options_Helper
61 + */
62 + protected $options_helper;
63 +
64 + /**
47 65 * The indexable repository.
48 66 *
49 67 * @var Indexable_Repository
50 68 */
@@ -50,22 +68,37 @@
50 68 */
51 69 protected $indexable_repository;
52 70
53 71 /**
72 + * Class that finds all images in a content string and extracts them.
73 + *
74 + * @var Image_Content_Extractor
75 + */
76 + private $image_content_extractor;
77 +
78 + /**
54 79 * Indexable_Link_Builder constructor.
55 80 *
56 81 * @param SEO_Links_Repository $seo_links_repository The SEO links repository.
57 82 * @param Url_Helper $url_helper The URL helper.
58 83 * @param Post_Helper $post_helper The post helper.
84 + * @param Options_Helper $options_helper The options helper.
85 + * @param Indexable_Helper $indexable_helper The indexable helper.
59 86 */
60 87 public function __construct(
61 88 SEO_Links_Repository $seo_links_repository,
62 89 Url_Helper $url_helper,
63 - Post_Helper $post_helper
90 + Post_Helper $post_helper,
91 + Options_Helper $options_helper,
92 + Indexable_Helper $indexable_helper,
93 + Image_Content_Extractor $image_content_extractor
64 94 ) {
65 - $this->seo_links_repository = $seo_links_repository;
66 - $this->url_helper = $url_helper;
67 - $this->post_helper = $post_helper;
95 + $this->seo_links_repository = $seo_links_repository;
96 + $this->url_helper = $url_helper;
97 + $this->post_helper = $post_helper;
98 + $this->options_helper = $options_helper;
99 + $this->indexable_helper = $indexable_helper;
100 + $this->image_content_extractor = $image_content_extractor;
68 101 }
69 102
70 103 /**
71 104 * Sets the indexable repository.
@@ -76,12 +109,9 @@
76 109 * @param Image_Helper $image_helper The image helper.
77 110 *
78 111 * @return void
79 112 */
80 - public function set_dependencies(
81 - Indexable_Repository $indexable_repository,
82 - Image_Helper $image_helper
83 - ) {
113 + public function set_dependencies( Indexable_Repository $indexable_repository, Image_Helper $image_helper ) {
84 114 $this->indexable_repository = $indexable_repository;
85 115 $this->image_helper = $image_helper;
86 116 }
87 117
@@ -93,8 +123,12 @@
93 123 *
94 124 * @return SEO_Links[] The created SEO links.
95 125 */
96 126 public function build( $indexable, $content ) {
127 + if ( ! $this->indexable_helper->should_index_indexable( $indexable ) ) {
128 + return [];
129 + }
130 +
97 131 global $post;
98 132 if ( $indexable->object_type === 'post' ) {
99 133 $post_backup = $post;
100 134 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
@@ -99,8 +133,11 @@
99 133 $post_backup = $post;
100 134 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
101 135 $post = $this->post_helper->get_post( $indexable->object_id );
102 136 \setup_postdata( $post );
137 +
138 + // The below hook primes the post and meta caches for all wp-image-<ID> images.
139 + // So the image loop in create_links() hits warm caches — no additional priming is needed there.
103 140 $content = \apply_filters( 'the_content', $content );
104 141 \wp_reset_postdata();
105 142 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
106 143 $post = $post_backup;
@@ -107,9 +144,9 @@
107 144 }
108 145
109 146 $content = \str_replace( ']]>', ']]&gt;', $content );
110 147 $links = $this->gather_links( $content );
111 - $images = $this->gather_images( $content );
148 + $images = $this->image_content_extractor->gather_images( $content );
112 149
113 150 if ( empty( $links ) && empty( $images ) ) {
114 151 $indexable->link_count = 0;
115 152 $this->update_related_indexables( $indexable, [] );
@@ -116,8 +153,12 @@
116 153
117 154 return [];
118 155 }
119 156
157 + if ( ! empty( $images ) && ( $indexable->open_graph_image_source === 'first-content-image' || $indexable->twitter_image_source === 'first-content-image' ) ) {
158 + $this->update_first_content_image( $indexable, $images );
159 + }
160 +
120 161 $links = $this->create_links( $indexable, $links, $images );
121 162
122 163 $this->update_related_indexables( $indexable, $links );
123 164
@@ -138,9 +179,11 @@
138 179 $this->seo_links_repository->delete_all_by_indexable_id( $indexable->id );
139 180
140 181 $linked_indexable_ids = [];
141 182 foreach ( $links as $link ) {
142 - $linked_indexable_ids[] = $link->target_indexable_id;
183 + if ( $link->target_indexable_id ) {
184 + $linked_indexable_ids[] = $link->target_indexable_id;
185 + }
143 186 }
144 187
145 188 $this->update_incoming_links_for_related_indexables( $linked_indexable_ids );
146 189 }
@@ -145,55 +188,58 @@
145 188 $this->update_incoming_links_for_related_indexables( $linked_indexable_ids );
146 189 }
147 190
148 191 /**
149 - * Gathers all links from content.
192 + * Fixes existing SEO links that are supposed to have a target indexable but don't, because of prior indexable
193 + * cleanup.
150 194 *
151 - * @param string $content The content.
195 + * @param Indexable $indexable The indexable to be the target of SEO Links.
152 196 *
153 - * @return string[] An array of urls.
197 + * @return void
154 198 */
155 - protected function gather_links( $content ) {
156 - if ( \strpos( $content, 'href' ) === false ) {
157 - // Nothing to do.
158 - return [];
159 - }
199 + public function patch_seo_links( Indexable $indexable ) {
200 + if ( ! empty( $indexable->id ) && ! empty( $indexable->object_id ) ) {
201 + $links = $this->seo_links_repository->find_all_by_target_post_id( $indexable->object_id );
160 202
161 - $links = [];
162 - $regexp = '<a\s[^>]*href=("??)([^" >]*?)\1[^>]*>';
163 - // Used modifiers iU to match case insensitive and make greedy quantifiers lazy.
164 - if ( \preg_match_all( "/$regexp/iU", $content, $matches, \PREG_SET_ORDER ) ) {
165 - foreach ( $matches as $match ) {
166 - $links[] = \trim( $match[2], "'" );
203 + $updated_indexable = false;
204 + foreach ( $links as $link ) {
205 + if ( \is_a( $link, SEO_Links::class ) && empty( $link->target_indexable_id ) ) {
206 + // Since that post ID exists in an SEO link but has no target_indexable_id, it's probably because of prior indexable cleanup.
207 + $this->seo_links_repository->update_target_indexable_id( $link->id, $indexable->id );
208 + $updated_indexable = true;
209 + }
167 210 }
211 +
212 + if ( $updated_indexable ) {
213 + $updated_indexable_id = [ $indexable->id ];
214 + $this->update_incoming_links_for_related_indexables( $updated_indexable_id );
215 + }
168 216 }
169 -
170 - return $links;
171 217 }
172 218
173 219 /**
174 - * Gathers all images from content.
220 + * Gathers all links from content.
175 221 *
176 222 * @param string $content The content.
177 223 *
178 224 * @return string[] An array of urls.
179 225 */
180 - protected function gather_images( $content ) {
181 - if ( \strpos( $content, 'src' ) === false ) {
226 + protected function gather_links( $content ) {
227 + if ( \strpos( $content, 'href' ) === false ) {
182 228 // Nothing to do.
183 229 return [];
184 230 }
185 231
186 - $images = [];
187 - $regexp = '<img\s[^>]*src=("??)([^" >]*?)\\1[^>]*>';
232 + $links = [];
233 + $regexp = '<a\s[^>]*href=("??)([^" >]*?)\1[^>]*>';
188 234 // Used modifiers iU to match case insensitive and make greedy quantifiers lazy.
189 235 if ( \preg_match_all( "/$regexp/iU", $content, $matches, \PREG_SET_ORDER ) ) {
190 236 foreach ( $matches as $match ) {
191 - $images[] = \trim( $match[2], "'" );
237 + $links[] = \trim( $match[2], "'" );
192 238 }
193 239 }
194 240
195 - return $images;
241 + return $links;
196 242 }
197 243
198 244 /**
199 245 * Creates link models from lists of URLs and image sources.
@@ -199,9 +245,9 @@
199 245 * Creates link models from lists of URLs and image sources.
200 246 *
201 247 * @param Indexable $indexable The indexable.
202 248 * @param string[] $links The link URLs.
203 - * @param string[] $images The image sources.
249 + * @param int[] $images The image sources.
204 250 *
205 251 * @return SEO_Links[] The link models.
206 252 */
207 253 protected function create_links( $indexable, $links, $images ) {
@@ -207,12 +253,12 @@
207 253 protected function create_links( $indexable, $links, $images ) {
208 254 $home_url = \wp_parse_url( \home_url() );
209 255 $current_url = \wp_parse_url( $indexable->permalink );
210 256 $links = \array_map(
211 - function( $link ) use ( $home_url, $indexable ) {
257 + function ( $link ) use ( $home_url, $indexable ) {
212 258 return $this->create_internal_link( $link, $home_url, $indexable );
213 259 },
214 - $links
260 + $links,
215 261 );
216 262 // Filter out links to the same page with a fragment or query.
217 263 $links = \array_filter(
218 264 $links,
@@ -217,18 +263,17 @@
217 263 $links = \array_filter(
218 264 $links,
219 265 function ( $link ) use ( $current_url ) {
220 266 return $this->filter_link( $link, $current_url );
221 - }
267 + },
222 268 );
223 269
224 - $images = \array_map(
225 - function( $link ) use ( $home_url, $indexable ) {
226 - return $this->create_internal_link( $link, $home_url, $indexable, true );
227 - },
228 - $images
229 - );
230 - return \array_merge( $links, $images );
270 + $image_links = [];
271 + foreach ( $images as $image_url => $image_id ) {
272 + $image_links[] = $this->create_internal_link( $image_url, $home_url, $indexable, true, $image_id );
273 + }
274 +
275 + return \array_merge( $links, $image_links );
231 276 }
232 277
233 278 /**
234 279 * Get the post ID based on the link's type and its target's permalink.
@@ -252,12 +297,13 @@
252 297 * @param string $url The url of the link.
253 298 * @param array $home_url The home url, as parsed by wp_parse_url.
254 299 * @param Indexable $indexable The indexable of the post containing the link.
255 300 * @param bool $is_image Whether or not the link is an image.
301 + * @param int $image_id The ID of the internal image.
256 302 *
257 303 * @return SEO_Links The created link.
258 304 */
259 - protected function create_internal_link( $url, $home_url, $indexable, $is_image = false ) {
305 + protected function create_internal_link( $url, $home_url, $indexable, $is_image = false, $image_id = 0 ) {
260 306 $parsed_url = \wp_parse_url( $url );
261 307 $link_type = $this->url_helper->get_link_type( $parsed_url, $home_url, $is_image );
262 308
263 309 /**
@@ -270,53 +316,86 @@
270 316 'url' => $url,
271 317 'type' => $link_type,
272 318 'indexable_id' => $indexable->id,
273 319 'post_id' => $indexable->object_id,
274 - ]
320 + ],
275 321 );
276 322
277 323 $model->parsed_url = $parsed_url;
278 324
279 - if ( $model->type === SEO_Links::TYPE_INTERNAL || $model->type === SEO_Links::TYPE_INTERNAL_IMAGE ) {
280 - $permalink = $this->get_permalink( $url, $home_url );
281 - if ( $this->url_helper->is_relative( $permalink ) ) {
282 - // Make sure we're checking against the absolute URL, and add a trailing slash if the site has a trailing slash in its permalink settings.
283 - $permalink = $this->url_helper->ensure_absolute_url( \user_trailingslashit( $permalink ) );
325 + if ( $model->type === SEO_Links::TYPE_INTERNAL ) {
326 + $permalink = $this->build_permalink( $url, $home_url );
327 +
328 + return $this->enhance_link_from_indexable( $model, $permalink );
329 + }
330 +
331 + if ( $model->type === SEO_Links::TYPE_INTERNAL_IMAGE ) {
332 + $permalink = $this->build_permalink( $url, $home_url );
333 +
334 + /** The `wpseo_force_creating_and_using_attachment_indexables` filter is documented in indexable-link-builder.php */
335 + if ( ! $this->options_helper->get( 'disable-attachment' ) || \apply_filters( 'wpseo_force_creating_and_using_attachment_indexables', false ) ) {
336 + $model = $this->enhance_link_from_indexable( $model, $permalink );
284 337 }
285 - $target = $this->indexable_repository->find_by_permalink( $permalink );
338 + else {
339 + $target_post_id = ( $image_id !== 0 ) ? $image_id : WPSEO_Image_Utils::get_attachment_by_url( $permalink );
286 340
287 - if ( ! $target ) {
288 - // If target indexable cannot be found, create one based on the post's post ID.
289 - $post_id = $this->get_post_id( $model->type, $permalink );
290 - if ( $post_id && $post_id !== 0 ) {
291 - $target = $this->indexable_repository->find_by_id_and_type( $post_id, 'post' );
341 + if ( ! empty( $target_post_id ) ) {
342 + $model->target_post_id = $target_post_id;
292 343 }
293 344 }
294 345
295 - if ( ! $target ) {
296 - return $model;
346 + if ( $model->target_post_id ) {
347 + $file = \get_attached_file( $model->target_post_id );
348 +
349 + if ( $file ) {
350 + if ( \file_exists( $file ) ) {
351 + $model->size = \filesize( $file );
352 + }
353 + else {
354 + $model->size = null;
355 + }
356 +
357 + [ , $width, $height ] = \wp_get_attachment_image_src( $model->target_post_id, 'full' );
358 + $model->width = $width;
359 + $model->height = $height;
360 + }
361 + else {
362 + $model->width = 0;
363 + $model->height = 0;
364 + $model->size = 0;
365 + }
297 366 }
367 + }
298 368
299 - $model->target_indexable_id = $target->id;
300 - if ( $target->object_type === 'post' ) {
301 - $model->target_post_id = $target->object_id;
369 + return $model;
370 + }
371 +
372 + /**
373 + * Enhances the link model with information from its indexable.
374 + *
375 + * @param SEO_Links $model The link's model.
376 + * @param string $permalink The link's permalink.
377 + *
378 + * @return SEO_Links The enhanced link model.
379 + */
380 + protected function enhance_link_from_indexable( $model, $permalink ) {
381 + $target = $this->indexable_repository->find_by_permalink( $permalink );
382 +
383 + if ( ! $target ) {
384 + // If target indexable cannot be found, create one based on the post's post ID.
385 + $post_id = $this->get_post_id( $model->type, $permalink );
386 + if ( $post_id && $post_id !== 0 ) {
387 + $target = $this->indexable_repository->find_by_id_and_type( $post_id, 'post' );
302 388 }
303 389 }
304 390
305 - if ( $is_image && $model->target_post_id ) {
306 - $file = \get_attached_file( $model->target_post_id );
307 - if ( $file ) {
308 - list( , $width, $height ) = \wp_get_attachment_image_src( $model->target_post_id, 'full' );
391 + if ( ! $target ) {
392 + return $model;
393 + }
309 394
310 - $model->width = $width;
311 - $model->height = $height;
312 - $model->size = \filesize( $file );
313 - }
314 - else {
315 - $model->width = 0;
316 - $model->height = 0;
317 - $model->size = 0;
318 - }
395 + $model->target_indexable_id = $target->id;
396 + if ( $target->object_type === 'post' ) {
397 + $model->target_post_id = $target->object_id;
319 398 }
320 399
321 400 if ( $model->target_indexable_id ) {
322 401 $model->language = $target->language;
@@ -326,8 +405,27 @@
326 405 return $model;
327 406 }
328 407
329 408 /**
409 + * Builds the link's permalink.
410 + *
411 + * @param string $url The url of the link.
412 + * @param array $home_url The home url, as parsed by wp_parse_url.
413 + *
414 + * @return string The link's permalink.
415 + */
416 + protected function build_permalink( $url, $home_url ) {
417 + $permalink = $this->get_permalink( $url, $home_url );
418 +
419 + if ( $this->url_helper->is_relative( $permalink ) ) {
420 + // Make sure we're checking against the absolute URL, and add a trailing slash if the site has a trailing slash in its permalink settings.
421 + $permalink = $this->url_helper->ensure_absolute_url( \user_trailingslashit( $permalink ) );
422 + }
423 +
424 + return $permalink;
425 + }
426 +
427 + /**
330 428 * Filters out links that point to the same page with a fragment or query.
331 429 *
332 430 * @param SEO_Links $link The link.
333 431 * @param array $current_url The url of the page the link is on, as parsed by wp_parse_url.
@@ -385,9 +483,11 @@
385 483 $updated_indexable_ids[] = $link->target_indexable_id;
386 484 }
387 485 }
388 486 foreach ( $links_to_remove as $link ) {
389 - $updated_indexable_ids[] = $link->target_indexable_id;
487 + if ( $link->target_indexable_id ) {
488 + $updated_indexable_ids[] = $link->target_indexable_id;
489 + }
390 490 }
391 491
392 492 $this->update_incoming_links_for_related_indexables( $updated_indexable_ids );
393 493 }
@@ -403,11 +503,11 @@
403 503 protected function links_diff( $links_a, $links_b ) {
404 504 return \array_udiff(
405 505 $links_a,
406 506 $links_b,
407 - static function( SEO_Links $link_a, SEO_Links $link_b ) {
507 + static function ( SEO_Links $link_a, SEO_Links $link_b ) {
408 508 return \strcmp( $link_a->url, $link_b->url );
409 - }
509 + },
410 510 );
411 511 }
412 512
413 513 /**
@@ -474,9 +574,42 @@
474 574 return;
475 575 }
476 576
477 577 $counts = $this->seo_links_repository->get_incoming_link_counts_for_indexable_ids( $related_indexable_ids );
578 +
579 + /**
580 + * Fires to signal that incoming link counts for related indexables were updated.
581 + *
582 + * @param int[] $related_indexable_ids The related indexable Ids to this link change.
583 + *
584 + * @internal
585 + */
586 + \do_action( 'wpseo_related_indexables_incoming_links_updated', $related_indexable_ids );
587 +
478 588 foreach ( $counts as $count ) {
479 589 $this->indexable_repository->update_incoming_link_count( $count['target_indexable_id'], $count['incoming'] );
590 + }
591 + }
592 +
593 + /**
594 + * Updates the image ids when the indexable images are marked as first content image.
595 + *
596 + * @param Indexable $indexable The indexable to change.
597 + * @param array<string|int> $images The image array.
598 + *
599 + * @return void
600 + */
601 + public function update_first_content_image( Indexable $indexable, array $images ): void {
602 + $current_open_graph_image = $indexable->open_graph_image;
603 + $current_twitter_image = $indexable->twitter_image;
604 +
605 + $first_content_image_url = \key( $images );
606 + $first_content_image_id = \current( $images );
607 +
608 + if ( $indexable->open_graph_image_source === 'first-content-image' && $current_open_graph_image === $first_content_image_url && ! empty( $first_content_image_id ) ) {
609 + $indexable->open_graph_image_id = $first_content_image_id;
610 + }
611 + if ( $indexable->twitter_image_source === 'first-content-image' && $current_twitter_image === $first_content_image_url && ! empty( $first_content_image_id ) ) {
612 + $indexable->twitter_image_id = $first_content_image_id;
480 613 }
481 614 }
482 615 }