PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 18.5.1
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v18.5.1
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 18.5.1, at src/builders/indexable-hierarchy-builder.php

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