PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.2
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.2
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-hierarchy-builder.php

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

417 lines 11.2 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 WP_Post;
6 use WP_Term;
7 use WPSEO_Meta;
8 use Yoast\WP\SEO\Helpers\Indexable_Helper;
9 use Yoast\WP\SEO\Helpers\Options_Helper;
10 use Yoast\WP\SEO\Helpers\Post_Helper;
11 use Yoast\WP\SEO\Models\Indexable;
12 use Yoast\WP\SEO\Repositories\Indexable_Hierarchy_Repository;
13 use Yoast\WP\SEO\Repositories\Indexable_Repository;
14 use Yoast\WP\SEO\Repositories\Primary_Term_Repository;
15
16 /**
17 * Builder for the indexables hierarchy.
18 *
19 * Builds the indexable hierarchy for indexables.
20 */
21 class Indexable_Hierarchy_Builder {
22
23 /**
24 * Holds a list of indexable ids where the ancestors are saved for.
25 *
26 * @var array<int>
27 */
28 protected $saved_ancestors = [];
29
30 /**
31 * The indexable repository.
32 *
33 * @var Indexable_Repository
34 */
35 private $indexable_repository;
36
37 /**
38 * The indexable hierarchy repository.
39 *
40 * @var Indexable_Hierarchy_Repository
41 */
42 private $indexable_hierarchy_repository;
43
44 /**
45 * The primary term repository.
46 *
47 * @var Primary_Term_Repository
48 */
49 private $primary_term_repository;
50
51 /**
52 * The options helper.
53 *
54 * @var Options_Helper
55 */
56 private $options;
57
58 /**
59 * Holds the Post_Helper instance.
60 *
61 * @var Post_Helper
62 */
63 private $post;
64
65 /**
66 * Holds the Indexable_Helper instance.
67 *
68 * @var Indexable_Helper
69 */
70 private $indexable_helper;
71
72 /**
73 * Indexable_Author_Builder constructor.
74 *
75 * @param Indexable_Hierarchy_Repository $indexable_hierarchy_repository The indexable hierarchy repository.
76 * @param Primary_Term_Repository $primary_term_repository The primary term repository.
77 * @param Options_Helper $options The options helper.
78 * @param Post_Helper $post The post helper.
79 * @param Indexable_Helper $indexable_helper The indexable helper.
80 */
81 public function __construct(
82 Indexable_Hierarchy_Repository $indexable_hierarchy_repository,
83 Primary_Term_Repository $primary_term_repository,
84 Options_Helper $options,
85 Post_Helper $post,
86 Indexable_Helper $indexable_helper
87 ) {
88 $this->indexable_hierarchy_repository = $indexable_hierarchy_repository;
89 $this->primary_term_repository = $primary_term_repository;
90 $this->options = $options;
91 $this->post = $post;
92 $this->indexable_helper = $indexable_helper;
93 }
94
95 /**
96 * Sets the indexable repository. Done to avoid circular dependencies.
97 *
98 * @required
99 *
100 * @param Indexable_Repository $indexable_repository The indexable repository.
101 *
102 * @return void
103 */
104 public function set_indexable_repository( Indexable_Repository $indexable_repository ) {
105 $this->indexable_repository = $indexable_repository;
106 }
107
108 /**
109 * Builds the ancestor hierarchy for an indexable.
110 *
111 * @param Indexable $indexable The indexable.
112 *
113 * @return Indexable The indexable.
114 */
115 public function build( Indexable $indexable ) {
116 if ( $this->hierarchy_is_built( $indexable ) ) {
117 return $indexable;
118 }
119
120 if ( ! $this->indexable_helper->should_index_indexable( $indexable ) ) {
121 return $indexable;
122 }
123
124 $this->indexable_hierarchy_repository->clear_ancestors( $indexable->id );
125 $indexable_id = $this->get_indexable_id( $indexable );
126 $ancestors = [];
127 if ( $indexable->object_type === 'post' ) {
128 $this->add_ancestors_for_post( $indexable_id, $indexable->object_id, $ancestors );
129 }
130
131 if ( $indexable->object_type === 'term' ) {
132 $this->add_ancestors_for_term( $indexable_id, $indexable->object_id, $ancestors );
133 }
134 $indexable->ancestors = \array_reverse( \array_values( $ancestors ) );
135 $indexable->has_ancestors = ! empty( $ancestors );
136 if ( $indexable->id ) {
137 $this->save_ancestors( $indexable );
138 }
139
140 return $indexable;
141 }
142
143 /**
144 * Checks if a hierarchy is built already for the given indexable.
145 *
146 * @param Indexable $indexable The indexable to check.
147 *
148 * @return bool True when indexable has a built hierarchy.
149 */
150 protected function hierarchy_is_built( Indexable $indexable ) {
151 if ( \in_array( $indexable->id, $this->saved_ancestors, true ) ) {
152 return true;
153 }
154
155 $this->saved_ancestors[] = $indexable->id;
156
157 return false;
158 }
159
160 /**
161 * Saves the ancestors.
162 *
163 * @param Indexable $indexable The indexable.
164 *
165 * @return void
166 */
167 private function save_ancestors( $indexable ) {
168 if ( empty( $indexable->ancestors ) ) {
169 $this->indexable_hierarchy_repository->add_ancestor( $indexable->id, 0, 0 );
170 return;
171 }
172 $depth = \count( $indexable->ancestors );
173 foreach ( $indexable->ancestors as $ancestor ) {
174 $this->indexable_hierarchy_repository->add_ancestor( $indexable->id, $ancestor->id, $depth );
175 --$depth;
176 }
177 }
178
179 /**
180 * Adds ancestors for a post.
181 *
182 * @param int $indexable_id The indexable id, this is the id of the original indexable.
183 * @param int $post_id The post id, this is the id of the post currently being evaluated.
184 * @param int[] $parents The indexable IDs of all parents.
185 *
186 * @return void
187 */
188 private function add_ancestors_for_post( $indexable_id, $post_id, &$parents ) {
189 $post = $this->post->get_post( $post_id );
190
191 if ( ! isset( $post->post_parent ) ) {
192 return;
193 }
194
195 if ( $post->post_parent !== 0 && $this->post->get_post( $post->post_parent ) !== null ) {
196 $ancestor = $this->indexable_repository->find_by_id_and_type( $post->post_parent, 'post' );
197 if ( $this->is_invalid_ancestor( $ancestor, $indexable_id, $parents ) ) {
198 return;
199 }
200
201 $parents[ $this->get_indexable_id( $ancestor ) ] = $ancestor;
202
203 $this->add_ancestors_for_post( $indexable_id, $ancestor->object_id, $parents );
204
205 return;
206 }
207
208 $primary_term_id = $this->find_primary_term_id_for_post( $post );
209
210 if ( $primary_term_id === 0 ) {
211 return;
212 }
213
214 $ancestor = $this->indexable_repository->find_by_id_and_type( $primary_term_id, 'term' );
215 if ( $this->is_invalid_ancestor( $ancestor, $indexable_id, $parents ) ) {
216 return;
217 }
218
219 $parents[ $this->get_indexable_id( $ancestor ) ] = $ancestor;
220
221 $this->add_ancestors_for_term( $indexable_id, $ancestor->object_id, $parents );
222 }
223
224 /**
225 * Adds ancestors for a term.
226 *
227 * @param int $indexable_id The indexable id, this is the id of the original indexable.
228 * @param int $term_id The term id, this is the id of the term currently being evaluated.
229 * @param int[] $parents The indexable IDs of all parents.
230 *
231 * @return void
232 */
233 private function add_ancestors_for_term( $indexable_id, $term_id, &$parents = [] ) {
234 $term = \get_term( $term_id );
235 $term_parents = $this->get_term_parents( $term );
236
237 foreach ( $term_parents as $parent ) {
238 $ancestor = $this->indexable_repository->find_by_id_and_type( $parent->term_id, 'term' );
239 if ( $this->is_invalid_ancestor( $ancestor, $indexable_id, $parents ) ) {
240 continue;
241 }
242
243 $parents[ $this->get_indexable_id( $ancestor ) ] = $ancestor;
244 }
245 }
246
247 /**
248 * Gets the primary term ID for a post.
249 *
250 * @param WP_Post $post The post.
251 *
252 * @return int The primary term ID. 0 if none exists.
253 */
254 private function find_primary_term_id_for_post( $post ) {
255 $main_taxonomy = $this->options->get( 'post_types-' . $post->post_type . '-maintax' );
256
257 if ( ! $main_taxonomy || $main_taxonomy === '0' ) {
258 return 0;
259 }
260
261 $primary_term_id = $this->get_primary_term_id( $post->ID, $main_taxonomy );
262
263 if ( $primary_term_id ) {
264 $term = \get_term( $primary_term_id );
265 if ( $term !== null && ! \is_wp_error( $term ) ) {
266 return $primary_term_id;
267 }
268 }
269
270 $terms = \get_the_terms( $post->ID, $main_taxonomy );
271
272 if ( ! \is_array( $terms ) || empty( $terms ) ) {
273 return 0;
274 }
275
276 return $this->find_deepest_term_id( $terms );
277 }
278
279 /**
280 * Find the deepest term in an array of term objects.
281 *
282 * @param array<WP_Term> $terms Terms set.
283 *
284 * @return int The deepest term ID.
285 */
286 private function find_deepest_term_id( $terms ) {
287 /*
288 * Let's find the deepest term in this array, by looping through and then
289 * unsetting every term that is used as a parent by another one in the array.
290 */
291 $terms_by_id = [];
292 foreach ( $terms as $term ) {
293 $terms_by_id[ $term->term_id ] = $term;
294 }
295 foreach ( $terms as $term ) {
296 unset( $terms_by_id[ $term->parent ] );
297 }
298
299 /*
300 * As we could still have two subcategories, from different parent categories,
301 * let's pick the one with the lowest ordered ancestor.
302 */
303 $parents_count = -1;
304 $term_order = 9999; // Because ASC.
305 $deepest_term = \reset( $terms_by_id );
306 foreach ( $terms_by_id as $term ) {
307 $parents = $this->get_term_parents( $term );
308
309 $new_parents_count = \count( $parents );
310
311 if ( $new_parents_count < $parents_count ) {
312 continue;
313 }
314
315 $parent_order = 9999; // Set default order.
316 foreach ( $parents as $parent ) {
317 if ( $parent->parent === 0 && isset( $parent->term_order ) ) {
318 $parent_order = $parent->term_order;
319 }
320 }
321
322 // Check if parent has lowest order.
323 if ( $new_parents_count > $parents_count || $parent_order < $term_order ) {
324 $term_order = $parent_order;
325 $deepest_term = $term;
326 }
327
328 $parents_count = $new_parents_count;
329 }
330
331 return $deepest_term->term_id;
332 }
333
334 /**
335 * Get a term's parents.
336 *
337 * @param WP_Term $term Term to get the parents for.
338 *
339 * @return WP_Term[] An array of all this term's parents.
340 */
341 private function get_term_parents( $term ) {
342 $tax = $term->taxonomy;
343 $parents = [];
344 while ( (int) $term->parent !== 0 ) {
345 $term = \get_term( $term->parent, $tax );
346 $parents[] = $term;
347 }
348
349 return $parents;
350 }
351
352 /**
353 * Checks if an ancestor is valid to add.
354 *
355 * @param Indexable $ancestor The ancestor (presumed indexable) to check.
356 * @param int $indexable_id The indexable id we're adding ancestors for.
357 * @param int[] $parents The indexable ids of the parents already added.
358 *
359 * @return bool
360 */
361 private function is_invalid_ancestor( $ancestor, $indexable_id, $parents ) {
362 // If the ancestor is not an Indexable, it is invalid by default.
363 if ( ! \is_a( $ancestor, 'Yoast\WP\SEO\Models\Indexable' ) ) {
364 return true;
365 }
366
367 // Don't add ancestors if they're unindexed, already added or the same as the main object.
368 if ( $ancestor->post_status === 'unindexed' ) {
369 return true;
370 }
371
372 $ancestor_id = $this->get_indexable_id( $ancestor );
373 if ( \array_key_exists( $ancestor_id, $parents ) ) {
374 return true;
375 }
376
377 if ( $ancestor_id === $indexable_id ) {
378 return true;
379 }
380
381 return false;
382 }
383
384 /**
385 * Returns the ID for an indexable. Catches situations where the id is null due to errors.
386 *
387 * @param Indexable $indexable The indexable.
388 *
389 * @return string|int A unique ID for the indexable.
390 */
391 private function get_indexable_id( Indexable $indexable ) {
392 if ( $indexable->id === 0 ) {
393 return "{$indexable->object_type}:{$indexable->object_id}";
394 }
395
396 return $indexable->id;
397 }
398
399 /**
400 * Returns the primary term id of a post.
401 *
402 * @param int $post_id The post ID.
403 * @param string $main_taxonomy The main taxonomy.
404 *
405 * @return int The ID of the primary term.
406 */
407 private function get_primary_term_id( $post_id, $main_taxonomy ) {
408 $primary_term = $this->primary_term_repository->find_by_post_id_and_taxonomy( $post_id, $main_taxonomy, false );
409
410 if ( $primary_term ) {
411 return $primary_term->term_id;
412 }
413
414 return \get_post_meta( $post_id, WPSEO_Meta::$meta_prefix . 'primary_' . $main_taxonomy, true );
415 }
416 }
417