-
Notifications
You must be signed in to change notification settings - Fork 969
Serve the aggregated schema map at /schemamap.xml #23510
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
thijsoo
merged 5 commits into
trunk
from
1161-schema-visualiser-update-discovery-mechanism
Jul 31, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
3418b71
refactor(schema-aggregator): extract the schema map XML provider
thijsoo 4291d0e
feat(schema-aggregator): serve the schema map at /schemamap.xml
thijsoo 055656d
fix(schema-aggregator): harden the schemamap.xml response
thijsoo 655ecb7
docs(schema-aggregator): explain the schema map render test seams
thijsoo ffee2cc
tiny doc changes
thijsoo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
73 changes: 73 additions & 0 deletions
73
src/schema-aggregator/application/schema_map/schema-map-xml-provider.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| <?php | ||
| // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. | ||
| namespace Yoast\WP\SEO\Schema_Aggregator\Application\Schema_Map; | ||
|
|
||
| use Yoast\WP\SEO\Schema_Aggregator\Application\Aggregate_Site_Schema_Map_Command; | ||
| use Yoast\WP\SEO\Schema_Aggregator\Application\Aggregate_Site_Schema_Map_Command_Handler; | ||
| use Yoast\WP\SEO\Schema_Aggregator\Application\Cache\Xml_Manager; | ||
| use Yoast\WP\SEO\Schema_Aggregator\Infrastructure\Aggregator_Config; | ||
|
|
||
| /** | ||
| * Provides the schema map XML, building and caching it when needed. | ||
| */ | ||
| class Schema_Map_Xml_Provider { | ||
|
|
||
| /** | ||
| * The command handler instance. | ||
| * | ||
| * @var Aggregate_Site_Schema_Map_Command_Handler | ||
| */ | ||
| private $aggregate_site_schema_map_command_handler; | ||
|
|
||
| /** | ||
| * The XML cache manager instance. | ||
| * | ||
| * @var Xml_Manager | ||
| */ | ||
| private $xml_cache_manager; | ||
|
|
||
| /** | ||
| * The aggregator configuration instance. | ||
| * | ||
| * @var Aggregator_Config | ||
| */ | ||
| private $aggregator_config; | ||
|
|
||
| /** | ||
| * Schema_Map_Xml_Provider constructor. | ||
| * | ||
| * @param Aggregate_Site_Schema_Map_Command_Handler $aggregate_site_schema_map_command_handler The command handler. | ||
| * @param Xml_Manager $xml_cache_manager The XML cache | ||
| * manager. | ||
| * @param Aggregator_Config $aggregator_config The aggregator | ||
| * configuration. | ||
| */ | ||
| public function __construct( | ||
| Aggregate_Site_Schema_Map_Command_Handler $aggregate_site_schema_map_command_handler, | ||
| Xml_Manager $xml_cache_manager, | ||
| Aggregator_Config $aggregator_config | ||
| ) { | ||
| $this->aggregate_site_schema_map_command_handler = $aggregate_site_schema_map_command_handler; | ||
| $this->xml_cache_manager = $xml_cache_manager; | ||
| $this->aggregator_config = $aggregator_config; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the schema map XML, building and caching it when it is not cached yet. | ||
| * | ||
| * @return string The schema map XML. | ||
| */ | ||
| public function get_xml(): string { | ||
| $cached_xml = $this->xml_cache_manager->get(); | ||
| if ( $cached_xml !== null ) { | ||
| return $cached_xml; | ||
| } | ||
|
|
||
| $command = new Aggregate_Site_Schema_Map_Command( $this->aggregator_config->get_allowed_post_types() ); | ||
| $xml = $this->aggregate_site_schema_map_command_handler->handle( $command ); | ||
|
|
||
| $this->xml_cache_manager->set( $xml ); | ||
|
|
||
| return $xml; | ||
| } | ||
| } |
195 changes: 195 additions & 0 deletions
195
src/schema-aggregator/user-interface/schemamap-xml-rewrite-integration.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| <?php | ||
| // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. | ||
| namespace Yoast\WP\SEO\Schema_Aggregator\User_Interface; | ||
|
|
||
| use WP_Query; | ||
| use Yoast\WP\SEO\Conditionals\Front_End_Conditional; | ||
| use Yoast\WP\SEO\Helpers\Redirect_Helper; | ||
| use Yoast\WP\SEO\Integrations\Integration_Interface; | ||
| use Yoast\WP\SEO\Schema_Aggregator\Application\Schema_Map\Schema_Map_Xml_Provider; | ||
| use Yoast\WP\SEO\Schema_Aggregator\Infrastructure\Schema_Aggregator_Conditional; | ||
| use Yoast_Dynamic_Rewrites; | ||
|
|
||
| /** | ||
| * Serves the schema map at the `schemamap.xml` path of the site. | ||
| * | ||
| * @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded | ||
| */ | ||
| class Schemamap_Xml_Rewrite_Integration implements Integration_Interface { | ||
|
|
||
| /** | ||
| * The query variable that flags a request for the schema map. | ||
| * | ||
| * @var string | ||
| */ | ||
| public const QUERY_VAR = 'yoast_schemamap'; | ||
|
|
||
| /** | ||
| * The schema map XML provider. | ||
| * | ||
| * @var Schema_Map_Xml_Provider | ||
| */ | ||
| private $schema_map_xml_provider; | ||
|
|
||
| /** | ||
| * The redirect helper. | ||
| * | ||
| * @var Redirect_Helper | ||
| */ | ||
| private $redirect_helper; | ||
|
|
||
| /** | ||
| * Returns the conditionals for this integration. | ||
| * | ||
| * The front end conditional keeps the rewrite rule out of admin requests, where the schema map is | ||
| * never served. It also means the rule is absent while Yoast SEO is being deactivated, so no | ||
| * equivalent of the `Deactivating_Yoast_Seo_Conditional` guard in `WPSEO_Sitemaps_Router` is needed. | ||
| * | ||
| * @return array<string> The conditionals that must be met to load this. | ||
| */ | ||
| public static function get_conditionals() { | ||
| return [ Schema_Aggregator_Conditional::class, Front_End_Conditional::class ]; | ||
| } | ||
|
|
||
| /** | ||
| * Schemamap_Xml_Rewrite_Integration constructor. | ||
| * | ||
| * @param Schema_Map_Xml_Provider $schema_map_xml_provider The schema map XML provider. | ||
| * @param Redirect_Helper $redirect_helper The redirect helper. | ||
| */ | ||
| public function __construct( Schema_Map_Xml_Provider $schema_map_xml_provider, Redirect_Helper $redirect_helper ) { | ||
| $this->schema_map_xml_provider = $schema_map_xml_provider; | ||
| $this->redirect_helper = $redirect_helper; | ||
| } | ||
|
|
||
| /** | ||
| * Registers the hooks for the integration. | ||
| * | ||
| * The schema map is rendered on `pre_get_posts` rather than `template_redirect`, the same way | ||
| * `WPSEO_Sitemaps::redirect()` renders sitemaps. That skips the main posts query, which this | ||
| * response never uses, and leaves fewer third-party callbacks able to echo ahead of the XML — | ||
| * anything printed before the document would stop it parsing as a sitemap. | ||
| * | ||
| * @return void | ||
| */ | ||
| public function register_hooks() { | ||
| /* | ||
| * Integrations register their hooks on `init` priority 10, which is after | ||
| * `yoast_add_dynamic_rewrite_rules` has already fired on `init` priority 1. The rule is | ||
| * therefore added directly, the same way `WPSEO_Sitemaps::register_sitemap()` does for | ||
| * sitemaps registered during `init`. Rewrite rules are not read until `WP::parse_request()`, | ||
| * so this is still in time. | ||
| */ | ||
| $this->add_rewrite_rules( Yoast_Dynamic_Rewrites::instance() ); | ||
|
|
||
| \add_filter( 'query_vars', [ $this, 'add_query_vars' ] ); | ||
| \add_action( 'pre_get_posts', [ $this, 'maybe_render_schema_map' ], 1 ); | ||
| } | ||
|
|
||
| /** | ||
| * Adds the rewrite rule for the schema map. | ||
| * | ||
| * @param Yoast_Dynamic_Rewrites $dynamic_rewrites Dynamic rewrites handler instance. | ||
| * | ||
| * @return void | ||
| */ | ||
| public function add_rewrite_rules( Yoast_Dynamic_Rewrites $dynamic_rewrites ) { | ||
| $dynamic_rewrites->add_rule( 'schemamap\.xml$', 'index.php?' . self::QUERY_VAR . '=1', 'top' ); | ||
| } | ||
|
|
||
| /** | ||
| * Adds the query variable for the schema map. | ||
| * | ||
| * @param array<string> $query_vars List of query variables to filter. | ||
| * | ||
| * @return array<string> Filtered query variables. | ||
| */ | ||
| public function add_query_vars( $query_vars ) { | ||
| $query_vars[] = self::QUERY_VAR; | ||
|
|
||
| return $query_vars; | ||
| } | ||
|
|
||
| /** | ||
| * Outputs the schema map when the main query is a request for it. | ||
| * | ||
| * @param WP_Query $query The query that is about to run. | ||
| * | ||
| * @return void | ||
| */ | ||
| public function maybe_render_schema_map( WP_Query $query ) { | ||
| if ( ! $query->is_main_query() || ! $query->get( self::QUERY_VAR ) ) { | ||
| return; | ||
| } | ||
|
|
||
| /* | ||
| * Without control over the headers the response cannot be typed as XML, and echoing the | ||
| * document into a half-written HTML body would produce neither a page nor a schema map. | ||
| */ | ||
| if ( $this->headers_already_sent() ) { | ||
| return; | ||
| } | ||
|
|
||
| /* | ||
| * Build the document before committing any headers. Generating it hits the database and can | ||
| * throw, and a failure after the 200 and the cache headers were sent would let a proxy store | ||
| * an HTML error page at this path for the full max-age. | ||
| */ | ||
| $xml = $this->schema_map_xml_provider->get_xml(); | ||
|
|
||
| $this->send_headers(); | ||
|
|
||
| // DOMDocument::createTextNode() already escaped every value; escaping again would corrupt the XML. | ||
| echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw XML. | ||
|
|
||
| $this->finish_request(); | ||
| } | ||
|
|
||
| /** | ||
| * Sends the response headers for the schema map. | ||
| * | ||
| * These replace what `WP::send_headers()` already sent: the HTML content type, and the `Expires` | ||
| * and `Cache-Control` pair that `wp_get_nocache_headers()` adds for logged-in users. | ||
| * | ||
| * @return void | ||
| */ | ||
| protected function send_headers() { | ||
| \status_header( 200 ); | ||
| // Prevent the search engines from indexing the schema map. | ||
| $this->redirect_helper->set_header( 'X-Robots-Tag: noindex, follow' ); | ||
| $this->redirect_helper->set_header( 'Content-Type: application/xml; charset=UTF-8' ); | ||
| $this->redirect_helper->set_header( 'Cache-Control: public, max-age=300' ); | ||
| // The response is public, so the no-cache Expires date from wp_get_nocache_headers() must go. | ||
| $this->redirect_helper->remove_header( 'Expires' ); | ||
| } | ||
|
|
||
| /** | ||
| * Tells whether the response headers have already been sent. | ||
| * | ||
| * Wrapped in an overridable method so the unit tests can drive both branches of | ||
| * maybe_render_schema_map(); the test process cannot control what headers_sent() reports. | ||
| * Protected rather than private because Mockery cannot stub private methods. | ||
| * | ||
| * @codeCoverageIgnore It only wraps a PHP function. | ||
| * | ||
| * @return bool Whether the headers have already been sent. | ||
| */ | ||
| protected function headers_already_sent(): bool { | ||
| return \headers_sent(); | ||
| } | ||
|
|
||
| /** | ||
| * Ends the request after the schema map has been output. | ||
| * | ||
| * The exit() is wrapped in an overridable method so the unit tests can stub it out; executing it | ||
| * would terminate the test runner. Protected rather than private because Mockery cannot stub | ||
| * private methods. | ||
| * | ||
| * @codeCoverageIgnore It only wraps a language construct that terminates the request. | ||
| * | ||
| * @return void | ||
| */ | ||
| protected function finish_request() { | ||
| exit(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think thats legit as well