PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.5
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.5
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 / integrations / front-end-integration.php

front-end-integration.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.5, at src/integrations/front-end-integration.php

652 lines 18.9 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\Integrations;
4
5 use WP_HTML_Tag_Processor;
6 use WPSEO_Replace_Vars;
7 use Yoast\WP\SEO\Conditionals\Dynamic_Product_Permalinks_Conditional;
8 use Yoast\WP\SEO\Conditionals\Front_End_Conditional;
9 use Yoast\WP\SEO\Conditionals\WooCommerce_Version_Conditional;
10 use Yoast\WP\SEO\Context\Meta_Tags_Context;
11 use Yoast\WP\SEO\Helpers\Options_Helper;
12 use Yoast\WP\SEO\Helpers\Permalink_Helper;
13 use Yoast\WP\SEO\Memoizers\Meta_Tags_Context_Memoizer;
14 use Yoast\WP\SEO\Presenters\Abstract_Indexable_Presenter;
15 use Yoast\WP\SEO\Presenters\Debug\Marker_Close_Presenter;
16 use Yoast\WP\SEO\Presenters\Debug\Marker_Open_Presenter;
17 use Yoast\WP\SEO\Presenters\Title_Presenter;
18 use Yoast\WP\SEO\Repositories\Indexable_Repository;
19 use Yoast\WP\SEO\Surfaces\Helpers_Surface;
20 use YoastSEO_Vendor\Symfony\Component\DependencyInjection\ContainerInterface;
21
22 /**
23 * Class Front_End_Integration.
24 */
25 class Front_End_Integration implements Integration_Interface {
26
27 /**
28 * The memoizer for the meta tags context.
29 *
30 * @var Meta_Tags_Context_Memoizer
31 */
32 private $context_memoizer;
33
34 /**
35 * The container.
36 *
37 * @var ContainerInterface
38 */
39 protected $container;
40
41 /**
42 * Represents the options helper.
43 *
44 * @var Options_Helper
45 */
46 protected $options;
47
48 /**
49 * Represents the permalink helper.
50 *
51 * @var Permalink_Helper
52 */
53 protected $permalink_helper;
54
55 /**
56 * The helpers surface.
57 *
58 * @var Helpers_Surface
59 */
60 protected $helpers;
61
62 /**
63 * The indexable repository.
64 *
65 * @var Indexable_Repository
66 */
67 protected $indexable_repository;
68
69 /**
70 * The replace vars helper.
71 *
72 * @var WPSEO_Replace_Vars
73 */
74 protected $replace_vars;
75
76 /**
77 * The presenters we loop through on each page load.
78 *
79 * @var string[]
80 */
81 protected $base_presenters = [
82 'Title',
83 'Meta_Description',
84 'Robots',
85 ];
86
87 /**
88 * The presenters we loop through on each page load.
89 *
90 * @var string[]
91 */
92 protected $indexing_directive_presenters = [
93 'Canonical',
94 'Rel_Prev',
95 'Rel_Next',
96 ];
97
98 /**
99 * The Open Graph specific presenters.
100 *
101 * @var string[]
102 */
103 protected $open_graph_presenters = [
104 'Open_Graph\Locale',
105 'Open_Graph\Type',
106 'Open_Graph\Title',
107 'Open_Graph\Description',
108 'Open_Graph\Url',
109 'Open_Graph\Site_Name',
110 'Open_Graph\Article_Publisher',
111 'Open_Graph\Article_Author',
112 'Open_Graph\Article_Published_Time',
113 'Open_Graph\Article_Modified_Time',
114 'Open_Graph\Image',
115 'Meta_Author',
116 ];
117
118 /**
119 * The Open Graph specific presenters that should be output on error pages.
120 *
121 * @var array<string>
122 */
123 protected $open_graph_error_presenters = [
124 'Open_Graph\Locale',
125 'Open_Graph\Title',
126 'Open_Graph\Site_Name',
127 ];
128
129 /**
130 * The Twitter card specific presenters.
131 *
132 * @var array<string>
133 */
134 protected $twitter_card_presenters = [
135 'Twitter\Card',
136 'Twitter\Title',
137 'Twitter\Description',
138 'Twitter\Image',
139 'Twitter\Creator',
140 'Twitter\Site',
141 ];
142
143 /**
144 * The Slack specific presenters.
145 *
146 * @var array<string>
147 */
148 protected $slack_presenters = [
149 'Slack\Enhanced_Data',
150 ];
151
152 /**
153 * The Webmaster verification specific presenters.
154 *
155 * @var array<string>
156 */
157 protected $webmaster_verification_presenters = [
158 'Webmaster\Ahrefs',
159 'Webmaster\Baidu',
160 'Webmaster\Bing',
161 'Webmaster\Google',
162 'Webmaster\Pinterest',
163 'Webmaster\Yandex',
164 ];
165
166 /**
167 * Presenters that are only needed on singular pages.
168 *
169 * @var array<string>
170 */
171 protected $singular_presenters = [
172 'Meta_Author',
173 'Open_Graph\Article_Author',
174 'Open_Graph\Article_Publisher',
175 'Open_Graph\Article_Published_Time',
176 'Open_Graph\Article_Modified_Time',
177 'Twitter\Creator',
178 'Slack\Enhanced_Data',
179 ];
180
181 /**
182 * The presenters we want to be last in our output.
183 *
184 * @var array<string>
185 */
186 protected $closing_presenters = [
187 'Schema',
188 ];
189
190 /**
191 * The next output.
192 *
193 * @var string
194 */
195 protected $next;
196
197 /**
198 * The prev output.
199 *
200 * @var string
201 */
202 protected $prev;
203
204 /**
205 * Returns the conditionals based on which this loadable should be active.
206 *
207 * @return array<string> The conditionals.
208 */
209 public static function get_conditionals() {
210 return [ Front_End_Conditional::class ];
211 }
212
213 /**
214 * Front_End_Integration constructor.
215 *
216 * @codeCoverageIgnore It sets dependencies.
217 *
218 * @param Meta_Tags_Context_Memoizer $context_memoizer The meta tags context memoizer.
219 * @param ContainerInterface $service_container The DI container.
220 * @param Options_Helper $options The options helper.
221 * @param Helpers_Surface $helpers The helpers surface.
222 * @param WPSEO_Replace_Vars $replace_vars The replace vars helper.
223 * @param Indexable_Repository $indexable_repository The indexable repository.
224 * @param Permalink_Helper $permalink_helper The permalink helper.
225 */
226 public function __construct(
227 Meta_Tags_Context_Memoizer $context_memoizer,
228 ContainerInterface $service_container,
229 Options_Helper $options,
230 Helpers_Surface $helpers,
231 WPSEO_Replace_Vars $replace_vars,
232 Indexable_Repository $indexable_repository,
233 Permalink_Helper $permalink_helper
234 ) {
235 $this->container = $service_container;
236 $this->context_memoizer = $context_memoizer;
237 $this->options = $options;
238 $this->helpers = $helpers;
239 $this->replace_vars = $replace_vars;
240 $this->indexable_repository = $indexable_repository;
241 $this->permalink_helper = $permalink_helper;
242 }
243
244 /**
245 * Registers the appropriate hooks to show the SEO metadata on the frontend.
246 *
247 * Removes some actions to remove metadata that WordPress shows on the frontend,
248 * to avoid duplicate and/or mismatched metadata.
249 *
250 * @return void
251 */
252 public function register_hooks() {
253 \add_filter( 'render_block', [ $this, 'query_loop_next_prev' ], 1, 2 );
254
255 \add_action( 'wp_head', [ $this, 'call_wpseo_head' ], 1 );
256 // Filter the title for compatibility with other plugins and themes.
257 \add_filter( 'wp_title', [ $this, 'filter_title' ], 15 );
258 // Filter the title for compatibility with block-based themes.
259 \add_filter( 'pre_get_document_title', [ $this, 'filter_title' ], 15 );
260
261 // Removes our robots presenter from the list when wp_robots is handling this.
262 \add_filter( 'wpseo_frontend_presenter_classes', [ $this, 'filter_robots_presenter' ] );
263
264 \add_action( 'wpseo_head', [ $this, 'present_head' ], -9999 );
265 \add_action( 'wpseo_head', [ $this, 'update_outdated_permalink' ], -10_000 );
266
267 \remove_action( 'wp_head', 'rel_canonical' );
268 \remove_action( 'wp_head', 'index_rel_link' );
269 \remove_action( 'wp_head', 'start_post_rel_link' );
270 \remove_action( 'wp_head', 'adjacent_posts_rel_link_wp_head' );
271 \remove_action( 'wp_head', 'noindex', 1 );
272 \remove_action( 'wp_head', '_wp_render_title_tag', 1 );
273 \remove_action( 'wp_head', '_block_template_render_title_tag', 1 );
274 \remove_action( 'wp_head', 'gutenberg_render_title_tag', 1 );
275 }
276
277 /**
278 * Filters the title, mainly used for compatibility reasons.
279 *
280 * @return string
281 */
282 public function filter_title() {
283 $context = $this->context_memoizer->for_current_page();
284
285 $title_presenter = new Title_Presenter();
286
287 /** This filter is documented in src/integrations/front-end-integration.php */
288 $title_presenter->presentation = \apply_filters( 'wpseo_frontend_presentation', $context->presentation, $context );
289 $title_presenter->replace_vars = $this->replace_vars;
290 $title_presenter->helpers = $this->helpers;
291
292 \remove_filter( 'pre_get_document_title', [ $this, 'filter_title' ], 15 );
293 $title = \esc_html( $title_presenter->get() );
294 \add_filter( 'pre_get_document_title', [ $this, 'filter_title' ], 15 );
295
296 return $title;
297 }
298
299 /**
300 * Checks if the current entity has a permalink that has a mismatch
301 * with the permalink stored in its indexable. If they differ, purges the indexable's
302 * permalink so it will be recalculated in the same request.
303 *
304 * @return void
305 */
306 public function update_outdated_permalink() {
307 $dynamic_permalinks_conditional = new Dynamic_Product_Permalinks_Conditional();
308 if ( ! $dynamic_permalinks_conditional->is_met() ) {
309 return;
310 }
311
312 $woocommerce_version_conditional = new WooCommerce_Version_Conditional();
313 if ( ! $woocommerce_version_conditional->is_met() ) {
314 return;
315 }
316
317 $context = $this->context_memoizer->for_current_page();
318
319 // We're adding this fix only for products because of the 10.5 Woo release. We might expand this for all cases in the future.
320 if ( $context->indexable->object_sub_type !== 'product' ) {
321 return;
322 }
323
324 $current_permalink = $this->permalink_helper->get_permalink_for_post( $context->indexable->object_sub_type, $context->indexable->object_id );
325 $indexable_permalink = $context->indexable->permalink;
326
327 // Only purge if the permalinks differ.
328 if ( $current_permalink !== $indexable_permalink ) {
329 $this->indexable_repository->reset_permalink(
330 $context->indexable->object_type,
331 $context->indexable->object_sub_type,
332 $context->indexable->object_id,
333 );
334
335 // Clear the memoizer caches so present_head() sees the updated indexable.
336 $this->context_memoizer->clear_for_current_page();
337 $this->context_memoizer->clear( $context->indexable );
338 }
339 }
340
341 /**
342 * Filters the next and prev links in the query loop block.
343 *
344 * @param string $html The HTML output.
345 * @param array<string|array|null> $block The block.
346 * @return string The filtered HTML output.
347 */
348 public function query_loop_next_prev( $html, $block ) {
349 if ( $block['blockName'] === 'core/query' ) {
350 // Check that the query does not inherit the main query.
351 if ( isset( $block['attrs']['query']['inherit'] ) && ! $block['attrs']['query']['inherit'] ) {
352 \add_filter( 'wpseo_adjacent_rel_url', [ $this, 'adjacent_rel_url' ], 1, 3 );
353 }
354 }
355
356 if ( $block['blockName'] === 'core/query-pagination-next' ) {
357 $this->next = $html;
358 }
359
360 if ( $block['blockName'] === 'core/query-pagination-previous' ) {
361 $this->prev = $html;
362 }
363
364 return $html;
365 }
366
367 /**
368 * Returns correct adjacent pages when Query loop block does not inherit query from template.
369 * Prioritizes existing prev and next links.
370 * Includes a safety check for full urls though it is not expected in the query pagination block.
371 *
372 * @param string $link The current link.
373 * @param string $rel Link relationship, prev or next.
374 * @param Indexable_Presentation|null $presentation The indexable presentation.
375 *
376 * @return string The correct link.
377 */
378 public function adjacent_rel_url( $link, $rel, $presentation = null ) {
379 // Prioritize existing prev and next links.
380 if ( $link ) {
381 return $link;
382 }
383
384 // Safety check for rel value.
385 if ( $rel !== 'next' && $rel !== 'prev' ) {
386 return $link;
387 }
388
389 // Check $this->next or $this->prev for existing links.
390 if ( $this->$rel === null ) {
391 return $link;
392 }
393
394 $processor = new WP_HTML_Tag_Processor( $this->$rel );
395
396 if ( ! $processor->next_tag( [ 'tag_name' => 'a' ] ) ) {
397 return $link;
398 }
399
400 $href = $processor->get_attribute( 'href' );
401
402 if ( ! $href ) {
403 return $link;
404 }
405
406 // Safety check for full url, not expected.
407 if ( \strpos( $href, 'http' ) === 0 ) {
408 return $href;
409 }
410
411 // Check if $href is relative and append last part of the url to permalink.
412 if ( \strpos( $href, '/' ) === 0 ) {
413 $href_parts = \explode( '/', $href );
414 return $presentation->permalink . \end( $href_parts );
415 }
416
417 return $link;
418 }
419
420 /**
421 * Filters our robots presenter, but only when wp_robots is attached to the wp_head action.
422 *
423 * @param array<string> $presenters The presenters for current page.
424 *
425 * @return array<string> The filtered presenters.
426 */
427 public function filter_robots_presenter( $presenters ) {
428 if ( ! \function_exists( 'wp_robots' ) ) {
429 return $presenters;
430 }
431
432 if ( ! \has_action( 'wp_head', 'wp_robots' ) ) {
433 return $presenters;
434 }
435
436 if ( \wp_is_serving_rest_request() ) {
437 return $presenters;
438 }
439
440 return \array_diff( $presenters, [ 'Yoast\\WP\\SEO\\Presenters\\Robots_Presenter' ] );
441 }
442
443 /**
444 * Presents the head in the front-end. Resets wp_query if it's not the main query.
445 *
446 * @codeCoverageIgnore It just calls a WordPress function.
447 *
448 * @return void
449 */
450 public function call_wpseo_head() {
451 global $wp_query;
452
453 $old_wp_query = $wp_query;
454 // phpcs:ignore WordPress.WP.DiscouragedFunctions.wp_reset_query_wp_reset_query -- Reason: The recommended function, wp_reset_postdata, doesn't reset wp_query.
455 \wp_reset_query();
456
457 \do_action( 'wpseo_head' );
458
459 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Reason: we have to restore the query.
460 $GLOBALS['wp_query'] = $old_wp_query;
461 }
462
463 /**
464 * Echoes all applicable presenters for a page.
465 *
466 * @return void
467 */
468 public function present_head() {
469 $context = $this->context_memoizer->for_current_page();
470 $presenters = $this->get_presenters( $context->page_type, $context );
471
472 /**
473 * Filter 'wpseo_frontend_presentation' - Allow filtering the presentation used to output our meta values.
474 *
475 * @param Indexable_Presentation $presentation The indexable presentation.
476 * @param Meta_Tags_Context $context The meta tags context for the current page.
477 */
478 $presentation = \apply_filters( 'wpseo_frontend_presentation', $context->presentation, $context );
479
480 echo \PHP_EOL;
481 foreach ( $presenters as $presenter ) {
482 $presenter->presentation = $presentation;
483 $presenter->helpers = $this->helpers;
484 $presenter->replace_vars = $this->replace_vars;
485
486 $output = $presenter->present();
487 if ( ! empty( $output ) ) {
488 // phpcs:ignore WordPress.Security.EscapeOutput -- Presenters are responsible for correctly escaping their output.
489 echo "\t" . $output . \PHP_EOL;
490 }
491 }
492 echo \PHP_EOL . \PHP_EOL;
493 }
494
495 /**
496 * Returns all presenters for this page.
497 *
498 * @param string $page_type The page type.
499 * @param Meta_Tags_Context|null $context The meta tags context for the current page.
500 *
501 * @return Abstract_Indexable_Presenter[] The presenters.
502 */
503 public function get_presenters( $page_type, $context = null ) {
504 $context ??= $this->context_memoizer->for_current_page();
505
506 $needed_presenters = $this->get_needed_presenters( $page_type );
507
508 $callback = static function ( $presenter ) {
509 if ( ! \class_exists( $presenter ) ) {
510 return null;
511 }
512 return new $presenter();
513 };
514 $presenters = \array_filter( \array_map( $callback, $needed_presenters ) );
515
516 /**
517 * Filter 'wpseo_frontend_presenters' - Allow filtering the presenter instances in or out of the request.
518 *
519 * @param Abstract_Indexable_Presenter[] $presenters List of presenter instances.
520 * @param Meta_Tags_Context $context The meta tags context for the current page.
521 */
522 $presenter_instances = \apply_filters( 'wpseo_frontend_presenters', $presenters, $context );
523
524 if ( ! \is_array( $presenter_instances ) ) {
525 $presenter_instances = $presenters;
526 }
527
528 $is_presenter_callback = static function ( $presenter_instance ) {
529 return $presenter_instance instanceof Abstract_Indexable_Presenter;
530 };
531 $presenter_instances = \array_filter( $presenter_instances, $is_presenter_callback );
532
533 return \array_merge(
534 [ new Marker_Open_Presenter() ],
535 $presenter_instances,
536 [ new Marker_Close_Presenter() ],
537 );
538 }
539
540 /**
541 * Generate the array of presenters we need for the current request.
542 *
543 * @param string $page_type The page type we're retrieving presenters for.
544 *
545 * @return string[] The presenters.
546 */
547 private function get_needed_presenters( $page_type ) {
548 $presenters = $this->get_presenters_for_page_type( $page_type );
549
550 $presenters = $this->maybe_remove_title_presenter( $presenters );
551
552 $callback = static function ( $presenter ) {
553 return "Yoast\WP\SEO\Presenters\\{$presenter}_Presenter";
554 };
555 $presenters = \array_map( $callback, $presenters );
556
557 /**
558 * Filter 'wpseo_frontend_presenter_classes' - Allow filtering presenters in or out of the request.
559 *
560 * @param array $presenters List of presenters.
561 * @param string $page_type The current page type.
562 */
563 $presenters = \apply_filters( 'wpseo_frontend_presenter_classes', $presenters, $page_type );
564
565 return $presenters;
566 }
567
568 /**
569 * Filters the presenters based on the page type.
570 *
571 * @param string $page_type The page type.
572 *
573 * @return string[] The presenters.
574 */
575 private function get_presenters_for_page_type( $page_type ) {
576 if ( $page_type === 'Error_Page' ) {
577 $presenters = $this->base_presenters;
578 if ( $this->options->get( 'opengraph' ) === true ) {
579 $presenters = \array_merge( $presenters, $this->open_graph_error_presenters );
580 }
581 return \array_merge( $presenters, $this->closing_presenters );
582 }
583
584 $presenters = $this->get_all_presenters();
585 if ( \in_array( $page_type, [ 'Static_Home_Page', 'Home_Page' ], true ) ) {
586 $presenters = \array_merge( $presenters, $this->webmaster_verification_presenters );
587 }
588
589 // Filter out the presenters only needed for singular pages on non-singular pages.
590 if ( ! \in_array( $page_type, [ 'Post_Type', 'Static_Home_Page' ], true ) ) {
591 $presenters = \array_diff( $presenters, $this->singular_presenters );
592 }
593
594 // Filter out `twitter:data` presenters for static home pages.
595 if ( $page_type === 'Static_Home_Page' ) {
596 $presenters = \array_diff( $presenters, $this->slack_presenters );
597 }
598
599 return $presenters;
600 }
601
602 /**
603 * Returns a list of all available presenters based on settings.
604 *
605 * @return string[] The presenters.
606 */
607 private function get_all_presenters() {
608 $presenters = \array_merge( $this->base_presenters, $this->indexing_directive_presenters );
609 if ( $this->options->get( 'opengraph' ) === true ) {
610 $presenters = \array_merge( $presenters, $this->open_graph_presenters );
611 }
612 if ( $this->options->get( 'twitter' ) === true && \apply_filters( 'wpseo_output_twitter_card', true ) !== false ) {
613 $presenters = \array_merge( $presenters, $this->twitter_card_presenters );
614 }
615 if ( $this->options->get( 'enable_enhanced_slack_sharing' ) === true && \apply_filters( 'wpseo_output_enhanced_slack_data', true ) !== false ) {
616 $presenters = \array_merge( $presenters, $this->slack_presenters );
617 }
618
619 return \array_merge( $presenters, $this->closing_presenters );
620 }
621
622 /**
623 * Whether the title presenter should be removed.
624 *
625 * @return bool True when the title presenter should be removed, false otherwise.
626 */
627 public function should_title_presenter_be_removed() {
628 return ! \get_theme_support( 'title-tag' ) && ! $this->options->get( 'forcerewritetitle', false );
629 }
630
631 /**
632 * Checks if the Title presenter needs to be removed.
633 *
634 * @param string[] $presenters The presenters.
635 *
636 * @return string[] The presenters.
637 */
638 private function maybe_remove_title_presenter( $presenters ) {
639 // Do not remove the title if we're on a REST request.
640 if ( \wp_is_serving_rest_request() ) {
641 return $presenters;
642 }
643
644 // Remove the title presenter if the theme is hardcoded to output a title tag so we don't have two title tags.
645 if ( $this->should_title_presenter_be_removed() ) {
646 $presenters = \array_diff( $presenters, [ 'Title' ] );
647 }
648
649 return $presenters;
650 }
651 }
652