PluginProbe
Parse.ly / 3.3.2
Parse.ly v3.3.2
3.24.1 3.24.0 3.23.7 3.23.6 3.23.5 3.23.4 3.23.3 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.17.0 3.18.0 3.18.1 3.19.0 3.19.1 3.19.2 3.19.3 3.2.0 3.2.1 3.20.0 3.20.1 3.20.2 3.20.3 All 105 releases
wp-parsely / src / class-metadata.php

class-metadata.php in Parse.ly 3.3.2, at src/class-metadata.php

626 lines 21.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metadata class
4 *
5 * @package Parsely
6 * @since 3.3.0
7 */
8
9 declare(strict_types=1);
10
11 namespace Parsely;
12
13 use WP_User;
14 use WP_Post;
15
16 /**
17 * Generates and inserts metadata readable by the Parse.ly Crawler.
18 *
19 * @since 1.0.0
20 * @since 3.3.0 Logic extracted from Parsely\Parsely class to separate file/class.
21 */
22 class Metadata {
23 /**
24 * Instance of Parsely class.
25 *
26 * @var Parsely
27 */
28 private $parsely;
29
30 /**
31 * Constructor.
32 *
33 * @param Parsely $parsely Instance of Parsely class.
34 */
35 public function __construct( Parsely $parsely ) {
36 $this->parsely = $parsely;
37 }
38
39 /**
40 * Creates Parse.ly metadata object from post metadata.
41 *
42 * @param WP_Post $post object.
43 *
44 * @return array<string, mixed>
45 */
46 public function construct_metadata( WP_Post $post ): array {
47 $options = $this->parsely->get_options();
48
49 $parsely_page = array(
50 '@context' => 'https://schema.org',
51 '@type' => 'WebPage',
52 );
53 $current_url = $this->get_current_url();
54 $queried_object_id = get_queried_object_id();
55
56 if ( is_front_page() && ! is_paged() ) {
57 $parsely_page['headline'] = $this->get_clean_parsely_page_value( get_bloginfo( 'name', 'raw' ) );
58 $parsely_page['url'] = home_url();
59 } elseif ( is_front_page() && is_paged() ) {
60 $parsely_page['headline'] = $this->get_clean_parsely_page_value( get_bloginfo( 'name', 'raw' ) );
61 $parsely_page['url'] = $current_url;
62 } elseif (
63 is_home() && (
64 ! ( 'page' === get_option( 'show_on_front' ) && ! get_option( 'page_on_front' ) ) ||
65 $queried_object_id && (int) get_option( 'page_for_posts' ) === $queried_object_id
66 )
67 ) {
68 $parsely_page['headline'] = get_the_title( get_option( 'page_for_posts', true ) );
69 $parsely_page['url'] = $current_url;
70 } elseif ( is_author() ) {
71 // TODO: why can't we have something like a WP_User object for all the other cases? Much nicer to deal with than functions.
72 $author = ( get_query_var( 'author_name' ) ) ? get_user_by( 'slug', get_query_var( 'author_name' ) ) : get_userdata( get_query_var( 'author' ) );
73 $parsely_page['headline'] = $this->get_clean_parsely_page_value( 'Author - ' . $author->data->display_name );
74 $parsely_page['url'] = $current_url;
75 } elseif ( is_category() || is_post_type_archive() || is_tax() ) {
76 $category = get_queried_object();
77 $parsely_page['headline'] = $this->get_clean_parsely_page_value( $category->name );
78 $parsely_page['url'] = $current_url;
79 } elseif ( is_date() ) {
80 if ( is_year() ) {
81 /* translators: %s: Archive year */
82 $parsely_page['headline'] = sprintf( __( 'Yearly Archive - %s', 'wp-parsely' ), get_the_time( 'Y' ) );
83 } elseif ( is_month() ) {
84 /* translators: %s: Archive month, formatted as F, Y */
85 $parsely_page['headline'] = sprintf( __( 'Monthly Archive - %s', 'wp-parsely' ), get_the_time( 'F, Y' ) );
86 } elseif ( is_day() ) {
87 /* translators: %s: Archive day, formatted as F jS, Y */
88 $parsely_page['headline'] = sprintf( __( 'Daily Archive - %s', 'wp-parsely' ), get_the_time( 'F jS, Y' ) );
89 } elseif ( is_time() ) {
90 /* translators: %s: Archive time, formatted as F jS g:i:s A */
91 $parsely_page['headline'] = sprintf( __( 'Hourly, Minutely, or Secondly Archive - %s', 'wp-parsely' ), get_the_time( 'F jS g:i:s A' ) );
92 }
93 $parsely_page['url'] = $current_url;
94 } elseif ( is_tag() ) {
95 $tag = single_tag_title( '', false );
96 if ( empty( $tag ) ) {
97 $tag = single_term_title( '', false );
98 }
99 /* translators: %s: Tag name */
100 $parsely_page['headline'] = $this->get_clean_parsely_page_value( sprintf( __( 'Tagged - %s', 'wp-parsely' ), $tag ) );
101 $parsely_page['url'] = $current_url;
102 } elseif ( in_array( get_post_type( $post ), $options['track_post_types'], true ) && Parsely::post_has_trackable_status( $post ) ) {
103 $authors = $this->get_author_names( $post );
104 $category = $this->get_category_name( $post, $options );
105
106 // Get featured image and thumbnail.
107 $image_url = get_the_post_thumbnail_url( $post, 'full' );
108 if ( ! is_string( $image_url ) ) {
109 $image_url = '';
110 }
111 $thumb_url = get_the_post_thumbnail_url( $post, 'thumbnail' );
112 if ( ! is_string( $thumb_url ) ) {
113 $thumb_url = '';
114 }
115
116 $tags = $this->get_tags( $post->ID );
117 if ( $options['cats_as_tags'] ) {
118 $tags = array_merge( $tags, $this->get_categories( $post->ID ) );
119 // add custom taxonomy values.
120 $tags = array_merge( $tags, $this->get_custom_taxonomy_values( $post ) );
121 }
122 // The function 'mb_strtolower' is not enabled by default in php, so this check
123 // falls back to the native php function 'strtolower' if necessary.
124 if ( function_exists( 'mb_strtolower' ) ) {
125 $lowercase_callback = 'mb_strtolower';
126 } else {
127 $lowercase_callback = 'strtolower';
128 }
129 if ( $options['lowercase_tags'] ) {
130 $tags = array_map( $lowercase_callback, $tags );
131 }
132
133 /**
134 * Filters the post tags that are used as metadata keywords.
135 *
136 * @param string[] $tags Post tags.
137 * @param int $ID Post ID.
138 *
139 * @since 1.8.0
140 */
141 $tags = apply_filters( 'wp_parsely_post_tags', $tags, $post->ID );
142 $tags = array_map( array( $this, 'get_clean_parsely_page_value' ), $tags );
143 $tags = array_values( array_unique( $tags ) );
144
145 /**
146 * Filters the JSON-LD @type.
147 *
148 * @param array $jsonld_type JSON-LD @type value, default is NewsArticle.
149 * @param int $id Post ID.
150 * @param string $post_type The Post type in WordPress.
151 *
152 * @since 2.5.0
153 */
154 $type = (string) apply_filters( 'wp_parsely_post_type', 'NewsArticle', $post->ID, $post->post_type );
155
156 // TODO: Merge only once, not every execution.
157 $supported_types = array_merge( Parsely::SUPPORTED_JSONLD_POST_TYPES, Parsely::SUPPORTED_JSONLD_NON_POST_TYPES );
158
159 // Validate type before passing it further as an invalid type will not be recognized by Parse.ly.
160 if ( ! in_array( $type, $supported_types, true ) ) {
161 $error = sprintf(
162 /* translators: 1: JSON @type like NewsArticle, 2: URL */
163 __( '@type %1$s is not supported by Parse.ly. Please use a type mentioned in %2$s', 'wp-parsely' ),
164 $type,
165 'https://www.parse.ly/help/integration/jsonld#distinguishing-between-posts-and-pages'
166 );
167 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
168 trigger_error( esc_html( $error ), E_USER_WARNING );
169 $type = 'NewsArticle';
170 }
171
172 $parsely_page['@type'] = $type;
173 $parsely_page['mainEntityOfPage'] = array(
174 '@type' => 'WebPage',
175 '@id' => $this->get_current_url( 'post' ),
176 );
177 $parsely_page['headline'] = $this->get_clean_parsely_page_value( get_the_title( $post ) );
178 $parsely_page['url'] = $this->get_current_url( 'post', $post->ID );
179 $parsely_page['thumbnailUrl'] = $thumb_url;
180 $parsely_page['image'] = array(
181 '@type' => 'ImageObject',
182 'url' => $image_url,
183 );
184
185 $this->set_metadata_post_times( $parsely_page, $post );
186
187 $parsely_page['articleSection'] = $category;
188 $author_objects = array();
189 foreach ( $authors as $author ) {
190 $author_tag = array(
191 '@type' => 'Person',
192 'name' => $author,
193 );
194 $author_objects[] = $author_tag;
195 }
196 $parsely_page['author'] = $author_objects;
197 $parsely_page['creator'] = $authors;
198 $parsely_page['publisher'] = array(
199 '@type' => 'Organization',
200 'name' => get_bloginfo( 'name' ),
201 'logo' => $options['logo'],
202 );
203 $parsely_page['keywords'] = $tags;
204 } elseif ( in_array( get_post_type(), $options['track_page_types'], true ) && Parsely::post_has_trackable_status( $post ) ) {
205 $parsely_page['headline'] = $this->get_clean_parsely_page_value( get_the_title( $post ) );
206 $parsely_page['url'] = $this->get_current_url( 'post' );
207 } elseif ( 'page' === get_option( 'show_on_front' ) && ! get_option( 'page_on_front' ) ) {
208 $parsely_page['headline'] = $this->get_clean_parsely_page_value( get_bloginfo( 'name', 'raw' ) );
209 $parsely_page['url'] = home_url();
210 }
211
212 /**
213 * Filters the structured metadata.
214 *
215 * @param array $parsely_page Existing structured metadata for a page.
216 * @param WP_Post $post Post object.
217 * @param array $options The Parse.ly options.
218 *
219 * @since 2.5.0
220 */
221 $filtered = apply_filters( 'wp_parsely_metadata', $parsely_page, $post, $options );
222 if ( is_array( $filtered ) ) {
223 return $filtered;
224 }
225
226 return array();
227 }
228
229 /**
230 * Sanitizes content.
231 *
232 * @since 2.6.0
233 * @since 3.3.0 Moved to class-metadata
234 *
235 * @param string|null $val The content you'd like sanitized.
236 * @return string
237 */
238 private function get_clean_parsely_page_value( ?string $val ): string {
239 if ( null === $val ) {
240 return '';
241 }
242
243 $val = str_replace( "\n", '', $val );
244 $val = str_replace( "\r", '', $val );
245 $val = wp_strip_all_tags( $val );
246 return trim( $val );
247 }
248
249 /**
250 * Retrieves all the authors for a post as an array. Can include multiple
251 * authors if coauthors plugin is in use.
252 *
253 * @since 3.3.0 Moved to class-metadata
254 *
255 * @param WP_Post $post The post object.
256 * @return array<string>
257 */
258 private function get_author_names( WP_Post $post ): array {
259 $authors = $this->get_coauthor_names( $post->ID );
260 if ( 0 === count( $authors ) ) {
261 $post_author = get_user_by( 'id', $post->post_author );
262 if ( false !== $post_author ) {
263 $authors = array( $post_author );
264 }
265 }
266
267 /**
268 * Filters the list of author WP_User objects for a post.
269 *
270 * @since 1.14.0
271 *
272 * @param WP_User[] $authors One or more authors as WP_User objects.
273 * @param WP_Post $post Post object.
274 */
275 $authors = apply_filters( 'wp_parsely_pre_authors', $authors, $post );
276
277 // Getting the author name for each author.
278 $authors = array_map( array( $this, 'get_author_name' ), $authors );
279
280 /**
281 * Filters the list of author names for a post.
282 *
283 * @since 1.14.0
284 *
285 * @param string[] $authors One or more author names.
286 * @param WP_Post $post Post object.
287 */
288 $authors = apply_filters( 'wp_parsely_post_authors', $authors, $post );
289
290 return array_map( array( $this, 'get_clean_parsely_page_value' ), $authors );
291 }
292
293 /**
294 * Returns a properly cleaned category/taxonomy value and will optionally
295 * use the top-level category/taxonomy value, if so instructed via the
296 * `use_top_level_cats` option.
297 *
298 * @since 3.3.0 Moved to class-metadata
299 *
300 * @param WP_Post $post_obj The object for the post.
301 * @param array<string, mixed> $parsely_options The parsely options.
302 * @return string Cleaned category name for the post in question.
303 */
304 private function get_category_name( WP_Post $post_obj, array $parsely_options ): string {
305 $taxonomy_dropdown_choice = get_the_terms( $post_obj->ID, $parsely_options['custom_taxonomy_section'] );
306 // Get top-level taxonomy name for chosen taxonomy and assign to $parent_name; it will be used
307 // as the category value if 'use_top_level_cats' option is checked.
308 // Assign as the default category name if no value is checked for the chosen taxonomy.
309 $category_name = get_cat_name( get_option( 'default_category' ) );
310 if ( ! empty( $taxonomy_dropdown_choice ) && ! is_wp_error( $taxonomy_dropdown_choice ) ) {
311 if ( $parsely_options['use_top_level_cats'] ) {
312 $first_term = array_shift( $taxonomy_dropdown_choice );
313 $term_name = $this->get_top_level_term( $first_term->term_id, $first_term->taxonomy );
314 } else {
315 $term_name = $this->get_bottom_level_term( $post_obj->ID, $parsely_options['custom_taxonomy_section'] );
316 }
317
318 if ( is_string( $term_name ) && 0 < strlen( $term_name ) ) {
319 $category_name = $term_name;
320 }
321 }
322
323 /**
324 * Filters the constructed category name that are used as metadata keywords.
325 *
326 * @since 1.8.0
327 *
328 * @param string $category Category name.
329 * @param WP_Post $post_obj Post object.
330 * @param array<string, mixed> $parsely_options The Parsely options.
331 */
332 $category_name = apply_filters( 'wp_parsely_post_category', $category_name, $post_obj, $parsely_options );
333
334 return $this->get_clean_parsely_page_value( $category_name );
335 }
336
337 /**
338 * Returns the tags associated with this page or post.
339 *
340 * @since 3.3.0 Moved to class-metadata
341 *
342 * @param int $post_id The id of the post you're trying to get tags for.
343 * @return array<string> The tags of the post represented by the post id.
344 */
345 private function get_tags( int $post_id ): array {
346 $tags = array();
347 $post_tags = wp_get_post_tags( $post_id );
348 if ( ! is_wp_error( $post_tags ) ) {
349 foreach ( $post_tags as $wp_tag ) {
350 $tags[] = $wp_tag->name;
351 }
352 }
353 return $tags;
354 }
355
356 /**
357 * Returns an array of all the child categories for the current post.
358 *
359 * @since 3.3.0 Moved to class-metadata
360 *
361 * @param int $post_id The id of the post you're trying to get categories for.
362 * @param string $delimiter What character will delimit the categories.
363 * @return array<string> All the child categories of the current post.
364 */
365 private function get_categories( int $post_id, string $delimiter = '/' ): array {
366 $tags = array();
367 foreach ( get_the_category( $post_id ) as $category ) {
368 $hierarchy = get_category_parents( $category->term_id, false, $delimiter );
369 if ( ! is_wp_error( $hierarchy ) ) {
370 $tags[] = rtrim( $hierarchy, '/' );
371 }
372 }
373 // take last element in the hierarchy, a string representing the full parent->child tree,
374 // and split it into individual category names.
375 $last_tag = end( $tags );
376 if ( false !== $last_tag ) {
377 $tags = explode( '/', $last_tag );
378 }
379
380 // Remove default category name from tags if needed.
381 $default_category_name = get_cat_name( get_option( 'default_category' ) );
382 return array_diff( $tags, array( $default_category_name ) );
383 }
384
385 /**
386 * Gets all term values from custom taxonomies.
387 *
388 * @since 3.3.0 Moved to class-metadata
389 *
390 * @param WP_Post $post_obj The post object.
391 * @return array<string>
392 */
393 private function get_custom_taxonomy_values( WP_Post $post_obj ): array {
394 // filter out default WordPress taxonomies.
395 $all_taxonomies = array_diff( get_taxonomies(), array( 'post_tag', 'nav_menu', 'author', 'link_category', 'post_format' ) );
396 $all_values = array();
397
398 foreach ( $all_taxonomies as $taxonomy ) {
399 $custom_taxonomy_objects = get_the_terms( $post_obj->ID, $taxonomy );
400 if ( is_array( $custom_taxonomy_objects ) ) {
401 foreach ( $custom_taxonomy_objects as $custom_taxonomy_object ) {
402 $all_values[] = $custom_taxonomy_object->name;
403 }
404 }
405 }
406
407 return $all_values;
408 }
409
410 /**
411 * Gets the URL of the current PHP script.
412 *
413 * A fall-back implementation to determine permalink.
414 *
415 * @since 3.0.0 $parsely_type Default parameter changed to `non-post`.
416 * @since 3.3.0 Moved to class-metadata
417 *
418 * @param string $parsely_type Optional. Parse.ly post type you're interested in, either 'post'
419 * or 'non-post'. Default is 'non-post'.
420 * @param int $post_id Optional. ID of the post you want to get the URL for. Default is
421 * 0, which means the global `$post` is used.
422 * @return string
423 */
424 private function get_current_url( string $parsely_type = 'non-post', int $post_id = 0 ): string {
425 if ( 'post' === $parsely_type ) {
426 $permalink = (string) get_permalink( $post_id );
427
428 /**
429 * Filters the permalink for a post.
430 *
431 * @since 1.14.0
432 * @since 2.5.0 Added $post_id.
433 *
434 * @param string $permalink The permalink URL or false if post does not exist.
435 * @param string $parsely_type Parse.ly type ("post" or "non-post").
436 * @param int $post_id ID of the post you want to get the URL for. May be 0, so
437 * $permalink will be for the global $post.
438 */
439 $url = apply_filters( 'wp_parsely_permalink', $permalink, $parsely_type, $post_id );
440 } else {
441 $request_uri = isset( $_SERVER['REQUEST_URI'] )
442 ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) )
443 : '';
444
445 $url = home_url( $request_uri );
446 }
447
448 $options = $this->parsely->get_options();
449 return $options['force_https_canonicals']
450 ? str_replace( 'http://', 'https://', $url )
451 : str_replace( 'https://', 'http://', $url );
452 }
453
454 /**
455 * Sets all metadata values related to post time.
456 *
457 * @since 3.0.2
458 * @since 3.3.0 Moved to class-metadata
459 *
460 * @param array $metadata Array containing all metadata. It will be potentially mutated to add
461 * keys: dateCreated, dateModified, & datePublished.
462 * @param WP_Post $post Post object from which to extract time data.
463 */
464 private function set_metadata_post_times( array &$metadata, WP_Post $post ): void {
465 $date_format = 'Y-m-d\TH:i:s\Z';
466 $post_created_gmt = get_post_time( $date_format, true, $post );
467
468 if ( false === $post_created_gmt ) {
469 return;
470 }
471
472 $metadata['dateCreated'] = $post_created_gmt;
473 $metadata['datePublished'] = $post_created_gmt;
474 $metadata['dateModified'] = $post_created_gmt;
475
476 $post_modified_gmt = get_post_modified_time( $date_format, true, $post );
477
478 if ( false !== $post_modified_gmt && $post_modified_gmt > $post_created_gmt ) {
479 $metadata['dateModified'] = $post_modified_gmt;
480 }
481 }
482
483 /**
484 * Returns a list of coauthors for a post assuming the Co-Authors Plus plugin
485 * is installed.
486 *
487 * Borrowed from
488 * https://github.com/Automattic/Co-Authors-Plus/blob/master/template-tags.php#L3-35
489 *
490 * @since 3.3.0 Moved to class-metadata
491 *
492 * @param int $post_id The id of the post.
493 * @return array<WP_User>
494 */
495 private function get_coauthor_names( int $post_id ): array {
496 $coauthors = array();
497 if ( class_exists( 'coauthors_plus' ) ) {
498 global $post, $post_ID, $coauthors_plus;
499
500 if ( ! $post_id && $post_ID ) {
501 $post_id = $post_ID;
502 }
503
504 if ( ! $post_id && $post ) {
505 $post_id = $post->ID;
506 }
507
508 if ( $post_id ) {
509 $coauthor_terms = get_the_terms( $post_id, $coauthors_plus->coauthor_taxonomy );
510
511 if ( is_array( $coauthor_terms ) ) {
512 foreach ( $coauthor_terms as $coauthor ) {
513 $coauthor_slug = preg_replace( '#^cap-#', '', $coauthor->slug );
514 $post_author = $coauthors_plus->get_coauthor_by( 'user_nicename', $coauthor_slug );
515 // In case the user has been deleted while plugin was deactivated.
516 if ( ! empty( $post_author ) ) {
517 $coauthors[] = new WP_User( $post_author );
518 }
519 }
520 } elseif ( ! $coauthors_plus->force_guest_authors ) {
521 if ( $post && $post_id === $post->ID ) {
522 $post_author = get_userdata( $post->post_author );
523 }
524 if ( ! empty( $post_author ) ) {
525 $coauthors[] = $post_author;
526 }
527 }
528 // The empty else case is because if we force guest authors, we don't ever care what value wp_posts.post_author has.
529 }
530 }
531 return $coauthors;
532 }
533
534 /**
535 * Determines author name from display name, falling back to firstname
536 * lastname, then nickname and finally the nicename.
537 *
538 * @since 3.3.0 Moved to class-metadata
539 *
540 * @param ?WP_User $author The author of the post.
541 * @return string
542 */
543 private function get_author_name( ?WP_User $author ): string {
544 // Gracefully handle situation where no author is available.
545 if ( null === $author ) {
546 return '';
547 }
548
549 if ( ! empty( $author->display_name ) ) {
550 return $author->display_name;
551 }
552
553 $author_name = $author->user_firstname . ' ' . $author->user_lastname;
554 if ( ' ' !== $author_name ) {
555 return $author_name;
556 }
557
558 if ( ! empty( $author->nickname ) ) {
559 return $author->nickname;
560 }
561
562 if ( ! empty( $author->user_nicename ) ) {
563 return $author->user_nicename;
564 }
565
566 return '';
567 }
568
569 /**
570 * Returns the top-most category/taxonomy value in a hierarcy given a
571 * taxonomy value's ID.
572 *
573 * (WordPress calls taxonomy values 'terms').
574 *
575 * @since 3.3.0 Moved to class-metadata
576 *
577 * @param int $term_id The id of the top level term.
578 * @param string $taxonomy_name The name of the taxonomy.
579 * @return string|false $parent The top level name of the category / taxonomy.
580 */
581 private function get_top_level_term( int $term_id, string $taxonomy_name ) {
582 $parent = get_term_by( 'id', $term_id, $taxonomy_name );
583 while ( false !== $parent && 0 !== $parent->parent ) {
584 $parent = get_term_by( 'id', $parent->parent, $taxonomy_name );
585 }
586 return $parent ? $parent->name : false;
587 }
588
589 /**
590 * Returns the bottom-most category/taxonomy value in a hierarchy given a
591 * post ID.
592 *
593 * (WordPress calls taxonomy values 'terms').
594 *
595 * @since 3.3.0 Moved to class-metadata
596 *
597 * @param int $post_id The post id you're interested in.
598 * @param string $taxonomy_name The name of the taxonomy.
599 * @return string Name of the custom taxonomy.
600 */
601 private function get_bottom_level_term( int $post_id, string $taxonomy_name ): string {
602 $terms = get_the_terms( $post_id, $taxonomy_name );
603
604 if ( ! is_array( $terms ) ) {
605 return '';
606 }
607
608 $term_ids = wp_list_pluck( $terms, 'term_id' );
609 $parents = array_filter( wp_list_pluck( $terms, 'parent' ) );
610
611 // Get array of IDs of terms which are not parents.
612 $term_ids_not_parents = array_diff( $term_ids, $parents );
613 // Get corresponding term objects, which are mapped to array index keys.
614 $terms_not_parents = array_intersect_key( $terms, $term_ids_not_parents );
615 // remove array index keys.
616 $terms_not_parents_cleaned = array_values( $terms_not_parents );
617
618 if ( ! empty( $terms_not_parents_cleaned ) ) {
619 // if you assign multiple child terms in a custom taxonomy, will only return the first.
620 return $terms_not_parents_cleaned[0]->name ?? '';
621 }
622
623 return '';
624 }
625 }
626