• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / wordpress-seo / d369ba79692f3a61843c9f85eef6af1be60080dc

05 Jun 2024 11:21AM UTC coverage: 52.64% (+0.01%) from 52.628%
d369ba79692f3a61843c9f85eef6af1be60080dc

push

github

web-flow
Merge pull request #21335 from Yoast/20335-yoastwpseoshould_index_indexables-filter-doesnt-disable-indexables

Make the filter that disables indexable creation, cover also the creation when a post is created and updated

7399 of 13455 branches covered (54.99%)

Branch coverage included in aggregate %.

73 of 78 new or added lines in 15 files covered. (93.59%)

841 existing lines in 1 file now uncovered.

28346 of 54449 relevant lines covered (52.06%)

61880.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.2
/src/builders/indexable-link-builder.php
1
<?php
2

3
namespace Yoast\WP\SEO\Builders;
4

5
use DOMDocument;
6
use WP_HTML_Tag_Processor;
7
use WPSEO_Image_Utils;
8
use Yoast\WP\SEO\Helpers\Image_Helper;
9
use Yoast\WP\SEO\Helpers\Indexable_Helper;
10
use Yoast\WP\SEO\Helpers\Options_Helper;
11
use Yoast\WP\SEO\Helpers\Post_Helper;
12
use Yoast\WP\SEO\Helpers\Url_Helper;
13
use Yoast\WP\SEO\Models\Indexable;
14
use Yoast\WP\SEO\Models\SEO_Links;
15
use Yoast\WP\SEO\Repositories\Indexable_Repository;
16
use Yoast\WP\SEO\Repositories\SEO_Links_Repository;
17

18
/**
19
 * Indexable link builder.
20
 */
21
class Indexable_Link_Builder {
22

23
        /**
24
         * The SEO links repository.
25
         *
26
         * @var SEO_Links_Repository
27
         */
28
        protected $seo_links_repository;
29

30
        /**
31
         * The url helper.
32
         *
33
         * @var Url_Helper
34
         */
35
        protected $url_helper;
36

37
        /**
38
         * The image helper.
39
         *
40
         * @var Image_Helper
41
         */
42
        protected $image_helper;
43

44
        /**
45
         * The indexable helper.
46
         *
47
         * @var Indexable_Helper
48
         */
49
        protected $indexable_helper;
50

51
        /**
52
         * The post helper.
53
         *
54
         * @var Post_Helper
55
         */
56
        protected $post_helper;
57

58
        /**
59
         * The options helper.
60
         *
61
         * @var Options_Helper
62
         */
63
        protected $options_helper;
64

65
        /**
66
         * The indexable repository.
67
         *
68
         * @var Indexable_Repository
69
         */
70
        protected $indexable_repository;
71

72
        /**
73
         * Indexable_Link_Builder constructor.
74
         *
75
         * @param SEO_Links_Repository $seo_links_repository The SEO links repository.
76
         * @param Url_Helper           $url_helper           The URL helper.
77
         * @param Post_Helper          $post_helper          The post helper.
78
         * @param Options_Helper       $options_helper       The options helper.
79
         * @param Indexable_Helper     $indexable_helper     The indexable helper.
80
         */
81
        public function __construct(
18✔
82
                SEO_Links_Repository $seo_links_repository,
83
                Url_Helper $url_helper,
84
                Post_Helper $post_helper,
85
                Options_Helper $options_helper,
86
                Indexable_Helper $indexable_helper
87
        ) {
9✔
88
                $this->seo_links_repository = $seo_links_repository;
18✔
89
                $this->url_helper           = $url_helper;
18✔
90
                $this->post_helper          = $post_helper;
18✔
91
                $this->options_helper       = $options_helper;
18✔
92
                $this->indexable_helper     = $indexable_helper;
18✔
93
        }
9✔
94

95
        /**
96
         * Sets the indexable repository.
97
         *
98
         * @required
99
         *
100
         * @param Indexable_Repository $indexable_repository The indexable repository.
101
         * @param Image_Helper         $image_helper         The image helper.
102
         *
103
         * @return void
104
         */
105
        public function set_dependencies(
18✔
106
                Indexable_Repository $indexable_repository,
107
                Image_Helper $image_helper
108
        ) {
9✔
109
                $this->indexable_repository = $indexable_repository;
18✔
110
                $this->image_helper         = $image_helper;
18✔
111
        }
9✔
112

113
        /**
114
         * Builds the links for a post.
115
         *
116
         * @param Indexable $indexable The indexable.
117
         * @param string    $content   The content. Expected to be unfiltered.
118
         *
119
         * @return SEO_Links[] The created SEO links.
120
         */
121
        public function build( $indexable, $content ) {
28✔
122
                if ( ! $this->indexable_helper->should_index_indexable( $indexable ) ) {
28✔
NEW
123
                        return [];
×
124
                }
125

126
                global $post;
28✔
127
                if ( $indexable->object_type === 'post' ) {
28✔
128
                        $post_backup = $post;
12✔
129
                        // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
130
                        $post = $this->post_helper->get_post( $indexable->object_id );
12✔
131
                        \setup_postdata( $post );
12✔
132
                        $content = \apply_filters( 'the_content', $content );
12✔
133
                        \wp_reset_postdata();
12✔
134
                        // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
135
                        $post = $post_backup;
12✔
136
                }
137

138
                $content = \str_replace( ']]>', ']]&gt;', $content );
28✔
139
                $links   = $this->gather_links( $content );
28✔
140
                $images  = $this->gather_images( $content );
28✔
141

142
                if ( empty( $links ) && empty( $images ) ) {
28✔
143
                        $indexable->link_count = 0;
6✔
144
                        $this->update_related_indexables( $indexable, [] );
6✔
145

146
                        return [];
6✔
147
                }
148

149
                $links = $this->create_links( $indexable, $links, $images );
22✔
150

151
                $this->update_related_indexables( $indexable, $links );
22✔
152

153
                $indexable->link_count = $this->get_internal_link_count( $links );
22✔
154

155
                return $links;
22✔
156
        }
157

158
        /**
159
         * Deletes all SEO links for an indexable.
160
         *
161
         * @param Indexable $indexable The indexable.
162
         *
163
         * @return void
164
         */
165
        public function delete( $indexable ) {
4✔
166
                $links = ( $this->seo_links_repository->find_all_by_indexable_id( $indexable->id ) );
4✔
167
                $this->seo_links_repository->delete_all_by_indexable_id( $indexable->id );
4✔
168

169
                $linked_indexable_ids = [];
4✔
170
                foreach ( $links as $link ) {
4✔
171
                        if ( $link->target_indexable_id ) {
2✔
172
                                $linked_indexable_ids[] = $link->target_indexable_id;
2✔
173
                        }
174
                }
175

176
                $this->update_incoming_links_for_related_indexables( $linked_indexable_ids );
4✔
177
        }
2✔
178

179
        /**
180
         * Fixes existing SEO links that are supposed to have a target indexable but don't, because of prior indexable
181
         * cleanup.
182
         *
183
         * @param Indexable $indexable The indexable to be the target of SEO Links.
184
         *
185
         * @return void
186
         */
187
        public function patch_seo_links( Indexable $indexable ) {
14✔
188
                if ( ! empty( $indexable->id ) && ! empty( $indexable->object_id ) ) {
14✔
189
                        $links = $this->seo_links_repository->find_all_by_target_post_id( $indexable->object_id );
8✔
190

191
                        $updated_indexable = false;
8✔
192
                        foreach ( $links as $link ) {
8✔
193
                                if ( \is_a( $link, SEO_Links::class ) && empty( $link->target_indexable_id ) ) {
6✔
194
                                        // Since that post ID exists in an SEO link but has no target_indexable_id, it's probably because of prior indexable cleanup.
195
                                        $this->seo_links_repository->update_target_indexable_id( $link->id, $indexable->id );
2✔
196
                                        $updated_indexable = true;
2✔
197
                                }
198
                        }
199

200
                        if ( $updated_indexable ) {
8✔
201
                                $updated_indexable_id = [ $indexable->id ];
2✔
202
                                $this->update_incoming_links_for_related_indexables( $updated_indexable_id );
2✔
203
                        }
204
                }
205
        }
7✔
206

207
        /**
208
         * Gathers all links from content.
209
         *
210
         * @param string $content The content.
211
         *
212
         * @return string[] An array of urls.
213
         */
214
        protected function gather_links( $content ) {
18✔
215
                if ( \strpos( $content, 'href' ) === false ) {
18✔
216
                        // Nothing to do.
217
                        return [];
14✔
218
                }
219

220
                $links  = [];
4✔
221
                $regexp = '<a\s[^>]*href=("??)([^" >]*?)\1[^>]*>';
4✔
222
                // Used modifiers iU to match case insensitive and make greedy quantifiers lazy.
223
                if ( \preg_match_all( "/$regexp/iU", $content, $matches, \PREG_SET_ORDER ) ) {
4✔
224
                        foreach ( $matches as $match ) {
4✔
225
                                $links[] = \trim( $match[2], "'" );
4✔
226
                        }
227
                }
228

229
                return $links;
4✔
230
        }
231

232
        /**
233
         * Gathers all images from content with WP's WP_HTML_Tag_Processor() and returns them along with their IDs, if
234
         * possible.
235
         *
236
         * @param string $content The content.
237
         *
238
         * @return int[] An associated array of image IDs, keyed by their URL.
239
         */
240
        protected function gather_images_wp( $content ) {
×
241
                $processor = new WP_HTML_Tag_Processor( $content );
×
242
                $images    = [];
×
243

244
                $query = [
245
                        'tag_name' => 'img',
×
246
                ];
247

248
                /**
249
                 * Filter 'wpseo_image_attribute_containing_id' - Allows filtering what attribute will be used to extract image IDs from.
250
                 *
251
                 * Defaults to "class", which is where WP natively stores the image IDs, in a `wp-image-<ID>` format.
252
                 *
253
                 * @api string The attribute to be used to extract image IDs from.
254
                 */
255
                $attribute = \apply_filters( 'wpseo_image_attribute_containing_id', 'class' );
×
256

257
                while ( $processor->next_tag( $query ) ) {
×
258
                        $src     = \htmlentities( $processor->get_attribute( 'src' ), ( \ENT_QUOTES | \ENT_SUBSTITUTE | \ENT_HTML401 ), \get_bloginfo( 'charset' ) );
×
259
                        $classes = $processor->get_attribute( $attribute );
×
260
                        $id      = $this->extract_id_of_classes( $classes );
×
261

262
                        $images[ $src ] = $id;
×
263
                }
264

265
                return $images;
×
266
        }
267

268
        /**
269
         * Gathers all images from content with DOMDocument() and returns them along with their IDs, if possible.
270
         *
271
         * @param string $content The content.
272
         *
273
         * @return int[] An associated array of image IDs, keyed by their URL.
274
         */
275
        protected function gather_images_domdocument( $content ) {
12✔
276
                $images  = [];
12✔
277
                $charset = \get_bloginfo( 'charset' );
12✔
278

279
                /**
280
                 * Filter 'wpseo_image_attribute_containing_id' - Allows filtering what attribute will be used to extract image IDs from.
281
                 *
282
                 * Defaults to "class", which is where WP natively stores the image IDs, in a `wp-image-<ID>` format.
283
                 *
284
                 * @api string The attribute to be used to extract image IDs from.
285
                 */
286
                $attribute = \apply_filters( 'wpseo_image_attribute_containing_id', 'class' );
12✔
287

288
                \libxml_use_internal_errors( true );
12✔
289
                $post_dom = new DOMDocument();
12✔
290
                $post_dom->loadHTML( '<?xml encoding="' . $charset . '">' . $content );
12✔
291
                \libxml_clear_errors();
12✔
292

293
                foreach ( $post_dom->getElementsByTagName( 'img' ) as $img ) {
12✔
294
                        $src     = \htmlentities( $img->getAttribute( 'src' ), ( \ENT_QUOTES | \ENT_SUBSTITUTE | \ENT_HTML401 ), $charset );
6✔
295
                        $classes = $img->getAttribute( $attribute );
6✔
296
                        $id      = $this->extract_id_of_classes( $classes );
6✔
297

298
                        $images[ $src ] = $id;
6✔
299
                }
300

301
                return $images;
12✔
302
        }
303

304
        /**
305
         * Extracts image ID out of the image's classes.
306
         *
307
         * @param string $classes The classes assigned to the image.
308
         *
309
         * @return int The ID that's extracted from the classes.
310
         */
311
        protected function extract_id_of_classes( $classes ) {
6✔
312
                if ( ! $classes ) {
6✔
313
                        return 0;
2✔
314
                }
315

316
                /**
317
                 * Filter 'wpseo_extract_id_pattern' - Allows filtering the regex patern to be used to extract image IDs from class/attribute names.
318
                 *
319
                 * Defaults to the pattern that extracts image IDs from core's `wp-image-<ID>` native format in image classes.
320
                 *
321
                 * @api string The regex pattern to be used to extract image IDs from class names. Empty string if the whole class/attribute should be returned.
322
                 */
323
                $pattern = \apply_filters( 'wpseo_extract_id_pattern', '/(?<!\S)wp-image-(\d+)(?!\S)/i' );
4✔
324

325
                if ( $pattern === '' ) {
4✔
326
                        return (int) $classes;
×
327
                }
328

329
                $matches = [];
4✔
330

331
                if ( \preg_match( $pattern, $classes, $matches ) ) {
4✔
332
                        return (int) $matches[1];
2✔
333
                }
334

335
                return 0;
2✔
336
        }
337

338
        /**
339
         * Gathers all images from content.
340
         *
341
         * @param string $content The content.
342
         *
343
         * @return int[] An associated array of image IDs, keyed by their URLs.
344
         */
345
        protected function gather_images( $content ) {
18✔
346

347
                /**
348
                 * Filter 'wpseo_force_creating_and_using_attachment_indexables' - Filters if we should use attachment indexables to find all content images. Instead of scanning the content.
349
                 *
350
                 * The default value is false.
351
                 *
352
                 * @since 21.1
353
                 */
354
                $should_not_parse_content = \apply_filters( 'wpseo_force_creating_and_using_attachment_indexables', false );
18✔
355

356
                /**
357
                 * Filter 'wpseo_force_skip_image_content_parsing' - Filters if we should force skip scanning the content to parse images.
358
                 * This filter can be used if the regex gives a faster result than scanning the code.
359
                 *
360
                 * The default value is false.
361
                 *
362
                 * @since 21.1
363
                 */
364
                $should_not_parse_content = \apply_filters( 'wpseo_force_skip_image_content_parsing', $should_not_parse_content );
18✔
365
                if ( ! $should_not_parse_content && \class_exists( WP_HTML_Tag_Processor::class ) ) {
18✔
366
                        return $this->gather_images_wp( $content );
×
367
                }
368

369
                if ( ! $should_not_parse_content && \class_exists( DOMDocument::class ) ) {
18✔
370
                        return $this->gather_images_DOMDocument( $content );
12✔
371
                }
372

373
                if ( \strpos( $content, 'src' ) === false ) {
6✔
374
                        // Nothing to do.
375
                        return [];
4✔
376
                }
377

378
                $images = [];
2✔
379
                $regexp = '<img\s[^>]*src=("??)([^" >]*?)\\1[^>]*>';
2✔
380
                // Used modifiers iU to match case insensitive and make greedy quantifiers lazy.
381
                if ( \preg_match_all( "/$regexp/iU", $content, $matches, \PREG_SET_ORDER ) ) {
2✔
382
                        foreach ( $matches as $match ) {
2✔
383
                                $images[ $match[2] ] = 0;
2✔
384
                        }
385
                }
386

387
                return $images;
2✔
388
        }
389

390
        /**
391
         * Creates link models from lists of URLs and image sources.
392
         *
393
         * @param Indexable $indexable The indexable.
394
         * @param string[]  $links     The link URLs.
395
         * @param int[]     $images    The image sources.
396
         *
397
         * @return SEO_Links[] The link models.
398
         */
399
        protected function create_links( $indexable, $links, $images ) {
22✔
400
                $home_url    = \wp_parse_url( \home_url() );
22✔
401
                $current_url = \wp_parse_url( $indexable->permalink );
22✔
402
                $links       = \array_map(
22✔
403
                        function ( $link ) use ( $home_url, $indexable ) {
11✔
404
                                return $this->create_internal_link( $link, $home_url, $indexable );
22✔
405
                        },
22✔
406
                        $links
11✔
407
                );
11✔
408
                // Filter out links to the same page with a fragment or query.
409
                $links = \array_filter(
22✔
410
                        $links,
13✔
411
                        function ( $link ) use ( $current_url ) {
11✔
412
                                return $this->filter_link( $link, $current_url );
413
                        }
11✔
414
                );
22✔
415

416
                $image_links = [];
18✔
417
                foreach ( $images as $image_url => $image_id ) {
418
                        $image_links[] = $this->create_internal_link( $image_url, $home_url, $indexable, true, $image_id );
419
                }
11✔
420

421
                return \array_merge( $links, $image_links );
422
        }
423

424
        /**
425
         * Get the post ID based on the link's type and its target's permalink.
426
         *
427
         * @param string $type      The type of link (either SEO_Links::TYPE_INTERNAL or SEO_Links::TYPE_INTERNAL_IMAGE).
428
         * @param string $permalink The permalink of the link's target.
429
         *
430
         * @return int The post ID.
431
         */
432
        protected function get_post_id( $type, $permalink ) {
1✔
433
                if ( $type === SEO_Links::TYPE_INTERNAL ) {
434
                        return \url_to_postid( $permalink );
435
                }
1✔
436

437
                return $this->image_helper->get_attachment_by_url( $permalink );
438
        }
439

440
        /**
441
         * Creates an internal link.
442
         *
443
         * @param string    $url       The url of the link.
444
         * @param array     $home_url  The home url, as parsed by wp_parse_url.
445
         * @param Indexable $indexable The indexable of the post containing the link.
446
         * @param bool      $is_image  Whether or not the link is an image.
447
         * @param int       $image_id  The ID of the internal image.
448
         *
449
         * @return SEO_Links The created link.
450
         */
451
        protected function create_internal_link( $url, $home_url, $indexable, $is_image = false, $image_id = 0 ) {
22✔
452
                $parsed_url = \wp_parse_url( $url );
453
                $link_type  = $this->url_helper->get_link_type( $parsed_url, $home_url, $is_image );
454

455
                /**
456
                 * ORM representing a link in the SEO Links table.
457
                 *
458
                 * @var SEO_Links $model
459
                 */
460
                $model = $this->seo_links_repository->query()->create(
22✔
461
                        [
22✔
462
                                'url'          => $url,
22✔
463
                                'type'         => $link_type,
22✔
464
                                'indexable_id' => $indexable->id,
11✔
465
                                'post_id'      => $indexable->object_id,
11✔
466
                        ]
11✔
467
                );
22✔
468

469
                $model->parsed_url = $parsed_url;
22✔
470

471
                if ( $model->type === SEO_Links::TYPE_INTERNAL ) {
472
                        $permalink = $this->build_permalink( $url, $home_url );
2✔
473

474
                        return $this->enhance_link_from_indexable( $model, $permalink );
475
                }
10✔
476

477
                if ( $model->type === SEO_Links::TYPE_INTERNAL_IMAGE ) {
478
                        $permalink = $this->build_permalink( $url, $home_url );
479

480
                        /** The `wpseo_force_creating_and_using_attachment_indexables` filter is documented in indexable-link-builder.php */
481
                        if ( ! $this->options_helper->get( 'disable-attachment' ) || \apply_filters( 'wpseo_force_creating_and_using_attachment_indexables', false ) ) {
482
                                $model = $this->enhance_link_from_indexable( $model, $permalink );
483
                        }
4✔
484
                        else {
485
                                $target_post_id = ( $image_id !== 0 ) ? $image_id : WPSEO_Image_Utils::get_attachment_by_url( $permalink );
8✔
486

487
                                if ( ! empty( $target_post_id ) ) {
488
                                        $model->target_post_id = $target_post_id;
489
                                }
490
                        }
5✔
491

492
                        if ( $model->target_post_id ) {
493
                                $file = \get_attached_file( $model->target_post_id );
8✔
494

495
                                if ( $file ) {
×
496
                                        if ( \file_exists( $file ) ) {
497
                                                $model->size = \filesize( $file );
498
                                        }
3✔
499
                                        else {
500
                                                $model->size = null;
501
                                        }
3✔
502

503
                                        [ , $width, $height ] = \wp_get_attachment_image_src( $model->target_post_id, 'full' );
6✔
504
                                        $model->width         = $width;
505
                                        $model->height        = $height;
506
                                }
1✔
507
                                else {
1✔
508
                                        $model->width  = 0;
2✔
509
                                        $model->height = 0;
510
                                        $model->size   = 0;
511
                                }
512
                        }
513
                }
10✔
514

515
                return $model;
516
        }
517

518
        /**
519
         * Enhances the link model with information from its indexable.
520
         *
521
         * @param SEO_Links $model     The link's model.
522
         * @param string    $permalink The link's permalink.
523
         *
524
         * @return SEO_Links The enhanced link model.
525
         */
526
        protected function enhance_link_from_indexable( $model, $permalink ) {
4✔
527
                $target = $this->indexable_repository->find_by_permalink( $permalink );
4✔
528

529
                if ( ! $target ) {
4✔
530
                        // If target indexable cannot be found, create one based on the post's post ID.
531
                        $post_id = $this->get_post_id( $model->type, $permalink );
2✔
532
                        if ( $post_id && $post_id !== 0 ) {
533
                                $target = $this->indexable_repository->find_by_id_and_type( $post_id, 'post' );
534
                        }
535
                }
2✔
536

537
                if ( ! $target ) {
538
                        return $model;
539
                }
1✔
540

541
                $model->target_indexable_id = $target->id;
2✔
542
                if ( $target->object_type === 'post' ) {
543
                        $model->target_post_id = $target->object_id;
544
                }
1✔
545

546
                if ( $model->target_indexable_id ) {
2✔
547
                        $model->language = $target->language;
548
                        $model->region   = $target->region;
549
                }
1✔
550

551
                return $model;
552
        }
553

554
        /**
555
         * Builds the link's permalink.
556
         *
557
         * @param string $url      The url of the link.
558
         * @param array  $home_url The home url, as parsed by wp_parse_url.
559
         *
560
         * @return string The link's permalink.
561
         */
562
        protected function build_permalink( $url, $home_url ) {
12✔
563
                $permalink = $this->get_permalink( $url, $home_url );
12✔
564

565
                if ( $this->url_helper->is_relative( $permalink ) ) {
10✔
566
                        // 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.
567
                        $permalink = $this->url_helper->ensure_absolute_url( \user_trailingslashit( $permalink ) );
568
                }
6✔
569

570
                return $permalink;
571
        }
572

573
        /**
574
         * Filters out links that point to the same page with a fragment or query.
575
         *
576
         * @param SEO_Links $link        The link.
577
         * @param array     $current_url The url of the page the link is on, as parsed by wp_parse_url.
578
         *
579
         * @return bool Whether or not the link should be filtered.
580
         */
581
        protected function filter_link( SEO_Links $link, $current_url ) {
582
                $url = $link->parsed_url;
583

584
                // Always keep external links.
585
                if ( $link->type === SEO_Links::TYPE_EXTERNAL ) {
586
                        return true;
587
                }
588

589
                // Always keep links with an empty path or pointing to other pages.
590
                if ( isset( $url['path'] ) ) {
591
                        return empty( $url['path'] ) || $url['path'] !== $current_url['path'];
592
                }
593

594
                // Only keep links to the current page without a fragment or query.
595
                return ( ! isset( $url['fragment'] ) && ! isset( $url['query'] ) );
596
        }
597

598
        /**
599
         * Updates the link counts for related indexables.
600
         *
601
         * @param Indexable   $indexable The indexable.
602
         * @param SEO_Links[] $links     The link models.
603
         *
604
         * @return void
605
         */
606
        protected function update_related_indexables( $indexable, $links ) {
28✔
607
                // Old links were only stored by post id, so remove all old seo links for this post that have no indexable id.
608
                // This can be removed if we ever fully clear all seo links.
609
                if ( $indexable->object_type === 'post' ) {
610
                        $this->seo_links_repository->delete_all_by_post_id_where_indexable_id_null( $indexable->object_id );
611
                }
14✔
612

613
                $updated_indexable_ids = [];
614
                $old_links             = $this->seo_links_repository->find_all_by_indexable_id( $indexable->id );
28✔
615

616
                $links_to_remove = $this->links_diff( $old_links, $links );
617
                $links_to_add    = $this->links_diff( $links, $old_links );
28✔
618

619
                if ( ! empty( $links_to_remove ) ) {
620
                        $this->seo_links_repository->delete_many_by_id( \wp_list_pluck( $links_to_remove, 'id' ) );
621
                }
14✔
622

623
                if ( ! empty( $links_to_add ) ) {
624
                        $this->seo_links_repository->insert_many( $links_to_add );
625
                }
14✔
626

627
                foreach ( $links_to_add as $link ) {
2✔
628
                        if ( $link->target_indexable_id ) {
629
                                $updated_indexable_ids[] = $link->target_indexable_id;
630
                        }
14✔
631
                }
6✔
632
                foreach ( $links_to_remove as $link ) {
12✔
633
                        if ( $link->target_indexable_id ) {
634
                                $updated_indexable_ids[] = $link->target_indexable_id;
635
                        }
636
                }
14✔
637

638
                $this->update_incoming_links_for_related_indexables( $updated_indexable_ids );
639
        }
640

641
        /**
642
         * Creates a diff between two arrays of SEO links, based on urls.
643
         *
644
         * @param SEO_Links[] $links_a The array to compare.
645
         * @param SEO_Links[] $links_b The array to compare against.
646
         *
647
         * @return SEO_Links[] Links that are in $links_a, but not in $links_b.
648
         */
649
        protected function links_diff( $links_a, $links_b ) {
18✔
650
                return \array_udiff(
18✔
651
                        $links_a,
15✔
652
                        $links_b,
18✔
653
                        static function ( SEO_Links $link_a, SEO_Links $link_b ) {
9✔
654
                                return \strcmp( $link_a->url, $link_b->url );
655
                        }
9✔
656
                );
9✔
657
        }
658

659
        /**
660
         * Returns the number of internal links in an array of link models.
661
         *
662
         * @param SEO_Links[] $links The link models.
663
         *
664
         * @return int The number of internal links.
665
         */
666
        protected function get_internal_link_count( $links ) {
12✔
667
                $internal_link_count = 0;
12✔
668

669
                foreach ( $links as $link ) {
670
                        if ( $link->type === SEO_Links::TYPE_INTERNAL ) {
671
                                ++$internal_link_count;
672
                        }
6✔
673
                }
674

675
                return $internal_link_count;
676
        }
677

678
        /**
679
         * Returns a cleaned permalink for a given link.
680
         *
681
         * @param string $link     The raw URL.
682
         * @param array  $home_url The home URL, as parsed by wp_parse_url.
683
         *
684
         * @return string The cleaned permalink.
685
         */
686
        protected function get_permalink( $link, $home_url ) {
22✔
687
                // Get rid of the #anchor.
688
                $url_split = \explode( '#', $link );
689
                $link      = $url_split[0];
22✔
690

691
                // Get rid of URL ?query=string.
692
                $url_split = \explode( '?', $link );
693
                $link      = $url_split[0];
22✔
694

695
                // Set the correct URL scheme.
696
                $link = \set_url_scheme( $link, $home_url['scheme'] );
22✔
697

698
                // Add 'www.' if it is absent and should be there.
699
                if ( \strpos( $home_url['host'], 'www.' ) === 0 && \strpos( $link, '://www.' ) === false ) {
700
                        $link = \str_replace( '://', '://www.', $link );
701
                }
11✔
702

703
                // Strip 'www.' if it is present and shouldn't be.
704
                if ( \strpos( $home_url['host'], 'www.' ) !== 0 ) {
705
                        $link = \str_replace( '://www.', '://', $link );
22✔
706
                }
707

708
                return $link;
709
        }
710

711
        /**
712
         * Updates incoming link counts for related indexables.
713
         *
714
         * @param int[] $related_indexable_ids The IDs of all related indexables.
715
         *
716
         * @return void
717
         */
718
        protected function update_incoming_links_for_related_indexables( $related_indexable_ids ) {
18✔
719
                if ( empty( $related_indexable_ids ) ) {
720
                        return;
18✔
721
                }
9✔
722

723
                $counts = $this->seo_links_repository->get_incoming_link_counts_for_indexable_ids( $related_indexable_ids );
724
                if ( \wp_cache_supports( 'flush_group' ) ) {
725
                        \wp_cache_flush_group( 'orphaned_counts' );
18✔
726
                }
9✔
727

728
                foreach ( $counts as $count ) {
18✔
729
                        $this->indexable_repository->update_incoming_link_count( $count['target_indexable_id'], $count['incoming'] );
×
730
                }
731
        }
732
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc