PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 19.0
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v19.0
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
wordpress-seo / src / builders / indexable-link-builder.php

indexable-link-builder.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 19.0, at src/builders/indexable-link-builder.php

483 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Yoast\WP\SEO\Builders;
4
5 use Yoast\WP\SEO\Helpers\Image_Helper;
6 use Yoast\WP\SEO\Helpers\Post_Helper;
7 use Yoast\WP\SEO\Helpers\Url_Helper;
8 use Yoast\WP\SEO\Models\Indexable;
9 use Yoast\WP\SEO\Models\SEO_Links;
10 use Yoast\WP\SEO\Repositories\Indexable_Repository;
11 use Yoast\WP\SEO\Repositories\SEO_Links_Repository;
12
13 /**
14 * Indexable link builder.
15 */
16 class Indexable_Link_Builder {
17
18 /**
19 * The SEO links repository.
20 *
21 * @var SEO_Links_Repository
22 */
23 protected $seo_links_repository;
24
25 /**
26 * The url helper.
27 *
28 * @var Url_Helper
29 */
30 protected $url_helper;
31
32 /**
33 * The image helper.
34 *
35 * @var Image_Helper
36 */
37 protected $image_helper;
38
39 /**
40 * The post helper.
41 *
42 * @var Post_Helper
43 */
44 protected $post_helper;
45
46 /**
47 * The indexable repository.
48 *
49 * @var Indexable_Repository
50 */
51 protected $indexable_repository;
52
53 /**
54 * Indexable_Link_Builder constructor.
55 *
56 * @param SEO_Links_Repository $seo_links_repository The SEO links repository.
57 * @param Url_Helper $url_helper The URL helper.
58 * @param Post_Helper $post_helper The post helper.
59 */
60 public function __construct(
61 SEO_Links_Repository $seo_links_repository,
62 Url_Helper $url_helper,
63 Post_Helper $post_helper
64 ) {
65 $this->seo_links_repository = $seo_links_repository;
66 $this->url_helper = $url_helper;
67 $this->post_helper = $post_helper;
68 }
69
70 /**
71 * Sets the indexable repository.
72 *
73 * @required
74 *
75 * @param Indexable_Repository $indexable_repository The indexable repository.
76 * @param Image_Helper $image_helper The image helper.
77 *
78 * @return void
79 */
80 public function set_dependencies(
81 Indexable_Repository $indexable_repository,
82 Image_Helper $image_helper
83 ) {
84 $this->indexable_repository = $indexable_repository;
85 $this->image_helper = $image_helper;
86 }
87
88 /**
89 * Builds the links for a post.
90 *
91 * @param Indexable $indexable The indexable.
92 * @param string $content The content. Expected to be unfiltered.
93 *
94 * @return SEO_Links[] The created SEO links.
95 */
96 public function build( $indexable, $content ) {
97 global $post;
98 if ( $indexable->object_type === 'post' ) {
99 $post_backup = $post;
100 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
101 $post = $this->post_helper->get_post( $indexable->object_id );
102 \setup_postdata( $post );
103 $content = \apply_filters( 'the_content', $content );
104 \wp_reset_postdata();
105 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
106 $post = $post_backup;
107 }
108
109 $content = \str_replace( ']]>', ']]&gt;', $content );
110 $links = $this->gather_links( $content );
111 $images = $this->gather_images( $content );
112
113 if ( empty( $links ) && empty( $images ) ) {
114 $indexable->link_count = 0;
115 $this->update_related_indexables( $indexable, [] );
116
117 return [];
118 }
119
120 $links = $this->create_links( $indexable, $links, $images );
121
122 $this->update_related_indexables( $indexable, $links );
123
124 $indexable->link_count = $this->get_internal_link_count( $links );
125
126 return $links;
127 }
128
129 /**
130 * Deletes all SEO links for an indexable.
131 *
132 * @param Indexable $indexable The indexable.
133 *
134 * @return void
135 */
136 public function delete( $indexable ) {
137 $links = ( $this->seo_links_repository->find_all_by_indexable_id( $indexable->id ) );
138 $this->seo_links_repository->delete_all_by_indexable_id( $indexable->id );
139
140 $linked_indexable_ids = [];
141 foreach ( $links as $link ) {
142 $linked_indexable_ids[] = $link->target_indexable_id;
143 }
144
145 $this->update_incoming_links_for_related_indexables( $linked_indexable_ids );
146 }
147
148 /**
149 * Gathers all links from content.
150 *
151 * @param string $content The content.
152 *
153 * @return string[] An array of urls.
154 */
155 protected function gather_links( $content ) {
156 if ( \strpos( $content, 'href' ) === false ) {
157 // Nothing to do.
158 return [];
159 }
160
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], "'" );
167 }
168 }
169
170 return $links;
171 }
172
173 /**
174 * Gathers all images from content.
175 *
176 * @param string $content The content.
177 *
178 * @return string[] An array of urls.
179 */
180 protected function gather_images( $content ) {
181 if ( \strpos( $content, 'src' ) === false ) {
182 // Nothing to do.
183 return [];
184 }
185
186 $images = [];
187 $regexp = '<img\s[^>]*src=("??)([^" >]*?)\\1[^>]*>';
188 // Used modifiers iU to match case insensitive and make greedy quantifiers lazy.
189 if ( \preg_match_all( "/$regexp/iU", $content, $matches, \PREG_SET_ORDER ) ) {
190 foreach ( $matches as $match ) {
191 $images[] = \trim( $match[2], "'" );
192 }
193 }
194
195 return $images;
196 }
197
198 /**
199 * Creates link models from lists of URLs and image sources.
200 *
201 * @param Indexable $indexable The indexable.
202 * @param string[] $links The link URLs.
203 * @param string[] $images The image sources.
204 *
205 * @return SEO_Links[] The link models.
206 */
207 protected function create_links( $indexable, $links, $images ) {
208 $home_url = \wp_parse_url( \home_url() );
209 $current_url = \wp_parse_url( $indexable->permalink );
210 $links = \array_map(
211 function( $link ) use ( $home_url, $indexable ) {
212 return $this->create_internal_link( $link, $home_url, $indexable );
213 },
214 $links
215 );
216 // Filter out links to the same page with a fragment or query.
217 $links = \array_filter(
218 $links,
219 function ( $link ) use ( $current_url ) {
220 return $this->filter_link( $link, $current_url );
221 }
222 );
223
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 );
231 }
232
233 /**
234 * Get the post ID based on the link's type and its target's permalink.
235 *
236 * @param string $type The type of link (either SEO_Links::TYPE_INTERNAL or SEO_Links::TYPE_INTERNAL_IMAGE).
237 * @param string $permalink The permalink of the link's target.
238 *
239 * @return int The post ID.
240 */
241 protected function get_post_id( $type, $permalink ) {
242 if ( $type === SEO_Links::TYPE_INTERNAL ) {
243 return \url_to_postid( $permalink );
244 }
245
246 return $this->image_helper->get_attachment_by_url( $permalink );
247 }
248
249 /**
250 * Creates an internal link.
251 *
252 * @param string $url The url of the link.
253 * @param array $home_url The home url, as parsed by wp_parse_url.
254 * @param Indexable $indexable The indexable of the post containing the link.
255 * @param bool $is_image Whether or not the link is an image.
256 *
257 * @return SEO_Links The created link.
258 */
259 protected function create_internal_link( $url, $home_url, $indexable, $is_image = false ) {
260 $parsed_url = \wp_parse_url( $url );
261 $link_type = $this->url_helper->get_link_type( $parsed_url, $home_url, $is_image );
262
263 /**
264 * ORM representing a link in the SEO Links table.
265 *
266 * @var SEO_Links $model
267 */
268 $model = $this->seo_links_repository->query()->create(
269 [
270 'url' => $url,
271 'type' => $link_type,
272 'indexable_id' => $indexable->id,
273 'post_id' => $indexable->object_id,
274 ]
275 );
276
277 $model->parsed_url = $parsed_url;
278
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 ) );
284 }
285 $target = $this->indexable_repository->find_by_permalink( $permalink );
286
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' );
292 }
293 }
294
295 if ( ! $target ) {
296 return $model;
297 }
298
299 $model->target_indexable_id = $target->id;
300 if ( $target->object_type === 'post' ) {
301 $model->target_post_id = $target->object_id;
302 }
303 }
304
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' );
309
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 }
319 }
320
321 if ( $model->target_indexable_id ) {
322 $model->language = $target->language;
323 $model->region = $target->region;
324 }
325
326 return $model;
327 }
328
329 /**
330 * Filters out links that point to the same page with a fragment or query.
331 *
332 * @param SEO_Links $link The link.
333 * @param array $current_url The url of the page the link is on, as parsed by wp_parse_url.
334 *
335 * @return bool Whether or not the link should be filtered.
336 */
337 protected function filter_link( SEO_Links $link, $current_url ) {
338 $url = $link->parsed_url;
339
340 // Always keep external links.
341 if ( $link->type === SEO_Links::TYPE_EXTERNAL ) {
342 return true;
343 }
344
345 // Always keep links with an empty path or pointing to other pages.
346 if ( isset( $url['path'] ) ) {
347 return empty( $url['path'] ) || $url['path'] !== $current_url['path'];
348 }
349
350 // Only keep links to the current page without a fragment or query.
351 return ( ! isset( $url['fragment'] ) && ! isset( $url['query'] ) );
352 }
353
354 /**
355 * Updates the link counts for related indexables.
356 *
357 * @param Indexable $indexable The indexable.
358 * @param SEO_Links[] $links The link models.
359 *
360 * @return void
361 */
362 protected function update_related_indexables( $indexable, $links ) {
363 // Old links were only stored by post id, so remove all old seo links for this post that have no indexable id.
364 // This can be removed if we ever fully clear all seo links.
365 if ( $indexable->object_type === 'post' ) {
366 $this->seo_links_repository->delete_all_by_post_id_where_indexable_id_null( $indexable->object_id );
367 }
368
369 $updated_indexable_ids = [];
370 $old_links = $this->seo_links_repository->find_all_by_indexable_id( $indexable->id );
371
372 $links_to_remove = $this->links_diff( $old_links, $links );
373 $links_to_add = $this->links_diff( $links, $old_links );
374
375 if ( ! empty( $links_to_remove ) ) {
376 $this->seo_links_repository->delete_many_by_id( \wp_list_pluck( $links_to_remove, 'id' ) );
377 }
378
379 if ( ! empty( $links_to_add ) ) {
380 $this->seo_links_repository->insert_many( $links_to_add );
381 }
382
383 foreach ( $links_to_add as $link ) {
384 if ( $link->target_indexable_id ) {
385 $updated_indexable_ids[] = $link->target_indexable_id;
386 }
387 }
388 foreach ( $links_to_remove as $link ) {
389 $updated_indexable_ids[] = $link->target_indexable_id;
390 }
391
392 $this->update_incoming_links_for_related_indexables( $updated_indexable_ids );
393 }
394
395 /**
396 * Creates a diff between two arrays of SEO links, based on urls.
397 *
398 * @param SEO_Links[] $links_a The array to compare.
399 * @param SEO_Links[] $links_b The array to compare against.
400 *
401 * @return SEO_Links[] Links that are in $links_a, but not in $links_b.
402 */
403 protected function links_diff( $links_a, $links_b ) {
404 return \array_udiff(
405 $links_a,
406 $links_b,
407 static function( SEO_Links $link_a, SEO_Links $link_b ) {
408 return \strcmp( $link_a->url, $link_b->url );
409 }
410 );
411 }
412
413 /**
414 * Returns the number of internal links in an array of link models.
415 *
416 * @param SEO_Links[] $links The link models.
417 *
418 * @return int The number of internal links.
419 */
420 protected function get_internal_link_count( $links ) {
421 $internal_link_count = 0;
422
423 foreach ( $links as $link ) {
424 if ( $link->type === SEO_Links::TYPE_INTERNAL ) {
425 ++$internal_link_count;
426 }
427 }
428
429 return $internal_link_count;
430 }
431
432 /**
433 * Returns a cleaned permalink for a given link.
434 *
435 * @param string $link The raw URL.
436 * @param array $home_url The home URL, as parsed by wp_parse_url.
437 *
438 * @return string The cleaned permalink.
439 */
440 protected function get_permalink( $link, $home_url ) {
441 // Get rid of the #anchor.
442 $url_split = \explode( '#', $link );
443 $link = $url_split[0];
444
445 // Get rid of URL ?query=string.
446 $url_split = \explode( '?', $link );
447 $link = $url_split[0];
448
449 // Set the correct URL scheme.
450 $link = \set_url_scheme( $link, $home_url['scheme'] );
451
452 // Add 'www.' if it is absent and should be there.
453 if ( \strpos( $home_url['host'], 'www.' ) === 0 && \strpos( $link, '://www.' ) === false ) {
454 $link = \str_replace( '://', '://www.', $link );
455 }
456
457 // Strip 'www.' if it is present and shouldn't be.
458 if ( \strpos( $home_url['host'], 'www.' ) !== 0 ) {
459 $link = \str_replace( '://www.', '://', $link );
460 }
461
462 return $link;
463 }
464
465 /**
466 * Updates incoming link counts for related indexables.
467 *
468 * @param int[] $related_indexable_ids The IDs of all related indexables.
469 *
470 * @return void
471 */
472 protected function update_incoming_links_for_related_indexables( $related_indexable_ids ) {
473 if ( empty( $related_indexable_ids ) ) {
474 return;
475 }
476
477 $counts = $this->seo_links_repository->get_incoming_link_counts_for_indexable_ids( $related_indexable_ids );
478 foreach ( $counts as $count ) {
479 $this->indexable_repository->update_incoming_link_count( $count['target_indexable_id'], $count['incoming'] );
480 }
481 }
482 }
483