PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.4
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.4
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 / schema-aggregator / user-interface / schemamap-xml-rewrite-integration.php

schemamap-xml-rewrite-integration.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.4, at src/schema-aggregator/user-interface/schemamap-xml-rewrite-integration.php

196 lines 6.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
3 namespace Yoast\WP\SEO\Schema_Aggregator\User_Interface;
4
5 use WP_Query;
6 use Yoast\WP\SEO\Conditionals\Front_End_Conditional;
7 use Yoast\WP\SEO\Helpers\Redirect_Helper;
8 use Yoast\WP\SEO\Integrations\Integration_Interface;
9 use Yoast\WP\SEO\Schema_Aggregator\Application\Schema_Map\Schema_Map_Xml_Provider;
10 use Yoast\WP\SEO\Schema_Aggregator\Infrastructure\Schema_Aggregator_Conditional;
11 use Yoast_Dynamic_Rewrites;
12
13 /**
14 * Serves the schema map at the `schemamap.xml` path of the site.
15 *
16 * @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
17 */
18 class Schemamap_Xml_Rewrite_Integration implements Integration_Interface {
19
20 /**
21 * The query variable that flags a request for the schema map.
22 *
23 * @var string
24 */
25 public const QUERY_VAR = 'yoast_schemamap';
26
27 /**
28 * The schema map XML provider.
29 *
30 * @var Schema_Map_Xml_Provider
31 */
32 private $schema_map_xml_provider;
33
34 /**
35 * The redirect helper.
36 *
37 * @var Redirect_Helper
38 */
39 private $redirect_helper;
40
41 /**
42 * Returns the conditionals for this integration.
43 *
44 * The front end conditional keeps the rewrite rule out of admin requests, where the schema map is
45 * never served. It also means the rule is absent while Yoast SEO is being deactivated, so no
46 * equivalent of the `Deactivating_Yoast_Seo_Conditional` guard in `WPSEO_Sitemaps_Router` is needed.
47 *
48 * @return array<string> The conditionals that must be met to load this.
49 */
50 public static function get_conditionals() {
51 return [ Schema_Aggregator_Conditional::class, Front_End_Conditional::class ];
52 }
53
54 /**
55 * Schemamap_Xml_Rewrite_Integration constructor.
56 *
57 * @param Schema_Map_Xml_Provider $schema_map_xml_provider The schema map XML provider.
58 * @param Redirect_Helper $redirect_helper The redirect helper.
59 */
60 public function __construct( Schema_Map_Xml_Provider $schema_map_xml_provider, Redirect_Helper $redirect_helper ) {
61 $this->schema_map_xml_provider = $schema_map_xml_provider;
62 $this->redirect_helper = $redirect_helper;
63 }
64
65 /**
66 * Registers the hooks for the integration.
67 *
68 * The schema map is rendered on `pre_get_posts` rather than `template_redirect`, the same way
69 * `WPSEO_Sitemaps::redirect()` renders sitemaps. That skips the main posts query, which this
70 * response never uses, and leaves fewer third-party callbacks able to echo ahead of the XML —
71 * anything printed before the document would stop it parsing as a sitemap.
72 *
73 * @return void
74 */
75 public function register_hooks() {
76 /*
77 * Integrations register their hooks on `init` priority 10, which is after
78 * `yoast_add_dynamic_rewrite_rules` has already fired on `init` priority 1. The rule is
79 * therefore added directly, the same way `WPSEO_Sitemaps::register_sitemap()` does for
80 * sitemaps registered during `init`. Rewrite rules are not read until `WP::parse_request()`,
81 * so this is still in time.
82 */
83 $this->add_rewrite_rules( Yoast_Dynamic_Rewrites::instance() );
84
85 \add_filter( 'query_vars', [ $this, 'add_query_vars' ] );
86 \add_action( 'pre_get_posts', [ $this, 'maybe_render_schema_map' ], 1 );
87 }
88
89 /**
90 * Adds the rewrite rule for the schema map.
91 *
92 * @param Yoast_Dynamic_Rewrites $dynamic_rewrites Dynamic rewrites handler instance.
93 *
94 * @return void
95 */
96 public function add_rewrite_rules( Yoast_Dynamic_Rewrites $dynamic_rewrites ) {
97 $dynamic_rewrites->add_rule( 'schemamap\.xml$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
98 }
99
100 /**
101 * Adds the query variable for the schema map.
102 *
103 * @param array<string> $query_vars List of query variables to filter.
104 *
105 * @return array<string> Filtered query variables.
106 */
107 public function add_query_vars( $query_vars ) {
108 $query_vars[] = self::QUERY_VAR;
109
110 return $query_vars;
111 }
112
113 /**
114 * Outputs the schema map when the main query is a request for it.
115 *
116 * @param WP_Query $query The query that is about to run.
117 *
118 * @return void
119 */
120 public function maybe_render_schema_map( WP_Query $query ) {
121 if ( ! $query->is_main_query() || ! $query->get( self::QUERY_VAR ) ) {
122 return;
123 }
124
125 /*
126 * Without control over the headers the response cannot be typed as XML, and echoing the
127 * document into a half-written HTML body would produce neither a page nor a schema map.
128 */
129 if ( $this->headers_already_sent() ) {
130 return;
131 }
132
133 /*
134 * Build the document before committing any headers. Generating it hits the database and can
135 * throw, and a failure after the 200 and the cache headers were sent would let a proxy store
136 * an HTML error page at this path for the full max-age.
137 */
138 $xml = $this->schema_map_xml_provider->get_xml();
139
140 $this->send_headers();
141
142 // DOMDocument::createTextNode() already escaped every value; escaping again would corrupt the XML.
143 echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw XML.
144
145 $this->finish_request();
146 }
147
148 /**
149 * Sends the response headers for the schema map.
150 *
151 * These replace what `WP::send_headers()` already sent: the HTML content type, and the `Expires`
152 * and `Cache-Control` pair that `wp_get_nocache_headers()` adds for logged-in users.
153 *
154 * @return void
155 */
156 protected function send_headers() {
157 \status_header( 200 );
158 // Prevent the search engines from indexing the schema map.
159 $this->redirect_helper->set_header( 'X-Robots-Tag: noindex, follow' );
160 $this->redirect_helper->set_header( 'Content-Type: application/xml; charset=UTF-8' );
161 $this->redirect_helper->set_header( 'Cache-Control: public, max-age=300' );
162 // The response is public, so the no-cache Expires date from wp_get_nocache_headers() must go.
163 $this->redirect_helper->remove_header( 'Expires' );
164 }
165
166 /**
167 * Tells whether the response headers have already been sent.
168 *
169 * Wrapped in an overridable method so the unit tests can drive both branches of
170 * maybe_render_schema_map(); the test process cannot control what headers_sent() reports.
171 * Protected rather than private because Mockery cannot stub private methods.
172 *
173 * @codeCoverageIgnore It only wraps a PHP function.
174 *
175 * @return bool Whether the headers have already been sent.
176 */
177 protected function headers_already_sent(): bool {
178 return \headers_sent();
179 }
180
181 /**
182 * Ends the request after the schema map has been output.
183 *
184 * The exit() is wrapped in an overridable method so the unit tests can stub it out; executing it
185 * would terminate the test runner. Protected rather than private because Mockery cannot stub
186 * private methods.
187 *
188 * @codeCoverageIgnore It only wraps a language construct that terminates the request.
189 *
190 * @return void
191 */
192 protected function finish_request() {
193 exit();
194 }
195 }
196