Skip to content

Serve the aggregated schema map at /schemamap.xml - #23510

Merged
thijsoo merged 5 commits into
trunkfrom
1161-schema-visualiser-update-discovery-mechanism
Jul 31, 2026
Merged

Serve the aggregated schema map at /schemamap.xml#23510
thijsoo merged 5 commits into
trunkfrom
1161-schema-visualiser-update-discovery-mechanism

Conversation

@thijsoo

@thijsoo thijsoo commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Context

  • The Schemamap directive was removed from robots.txt in Yoast/reserved-tasks#1153, because it caused "Syntax not understood" errors in Google Search Console and Bing Webmaster Tools, and "robots.txt is invalid" warnings in PageSpeed Insights.
  • The schema visualiser used that directive to discover a site's schema map. Yoast/schema-visualizer#4 replaced it with three probes, in order: the well-known root /schemamap.xml, a <link rel="schemamap"> tag, and the /wp-json/yoast/v1/schema-aggregator/get-xml endpoint.
  • The plugin only served the third one, so every Yoast site fell through to the slowest fallback. This PR serves the first.

Summary

This PR can be summarized in the following changelog entry:

  • Adds a schemamap.xml file at the site root that exposes the aggregated schema map.

Relevant technical choices:

  • The route mirrors WordPress core's own WP_Sitemaps: a rewrite rule pointing at a query variable, and a handler that echoes the XML and exits. The content type is application/xml; charset=UTF-8, which is what both core's sitemap renderer and our existing REST route already send.
  • Yoast_Dynamic_Rewrites is used instead of add_rewrite_rule(), so the rule appears and disappears with the feature toggle without ever needing a rewrite flush. Nothing is written to the rewrite_rules option.
  • Known limitation: under plain (non-pretty) permalinks, rewrite rules do not run, so /schemamap.xml returns a 404 — exactly as /sitemap_index.xml does today. Discovery falls back to the wp-json endpoint there. The same applies to sub-directory installs, where the visualiser probes the domain root rather than the WordPress root; WordPress core has the same constraint for robots.txt.

Test instructions

Test instructions for the acceptance test before the PR gets merged

This PR can be acceptance tested by following these steps:

Preconditions: a site with Yoast SEO from this branch, logged in as an administrator, with a handful of published posts and pages.
The site should not be a multisite/subsite.

  1. Go to Settings → Permalinks and pick any option other than "Plain" (for example "Post name"). Save.
  2. Go to Yoast SEO → Settings → Site features and switch on the setting for the schema aggregation endpoint. Save.
  3. In your browser, visit https://<your-site>/schemamap.xml.
    • Expected: an XML document is shown, starting with <?xml and containing a <urlset> with one or more <url> entries. It should not be your theme's 404 page.
  4. Visit https://<your-site>/wp-json/yoast/v1/schema-aggregator/get-xml and compare.
    • Expected: the same XML document as in step 3.
  5. Visit https://<your-site>/schemamap.xml/ (with a trailing slash).
    • Expected: the same XML document, and the browser does not bounce between URLs or report a redirect loop.
  6. Publish a new post, then reload https://<your-site>/schemamap.xml.
    • Expected: the document reflects the new content — the relevant <lastmod> date has changed.
  7. Go back to Yoast SEO → Settings → Site features and switch the schema aggregation endpoint off. Save. Reload https://<your-site>/schemamap.xml.
    • Expected: a normal 404 page. Importantly, you should not have to visit Settings → Permalinks to make this take effect.
  8. Switch the setting back on, then go to Settings → Permalinks, choose Plain, and save. Visit https://<your-site>/schemamap.xml.
    • Expected: a 404 page. This is intended and matches how /sitemap_index.xml behaves. Confirm https://<your-site>?rest_route=/yoast/v1/schema-aggregator/get-xml still returns the XML.
  9. Set the permalinks back to "Post name". While still logged in as administrator, reload https://<your-site>/schemamap.xml and inspect the response headers (browser devtools → Network tab → select the request).
    • Expected: Content-Type: application/xml; charset=UTF-8, Cache-Control: public, max-age=300, X-Robots-Tag: noindex, follow, and no Expires header dated 1984.
  10. Confirm nothing else regressed: https://<your-site>/sitemap_index.xml still loads, and https://<your-site>/robots.txt loads and contains no Schemamap: line.

Relevant test scenarios

  • Changes should be tested with the browser console open
  • Changes should be tested on different posts/pages/taxonomies/custom post types/custom taxonomies
  • Changes should be tested on different editors (Default Block/Gutenberg/Classic/Elementor/other)
  • Changes should be tested on different browsers
  • Changes should be tested on multisite
  • Browser console / network tab: step 9 needs the Network tab to inspect the response headers. Also worth confirming no PHP notice or stray output appears before the <?xml declaration — anything printed ahead of it stops the document being recognised as a schema map.
  • Multisite: please test subdirectory multisite in particular. Each site should serve its own /<site>/schemamap.xml with its own content, and toggling the setting on one site should not affect another. Note that on a subdirectory site the visualiser's root probe will not reach it — that is a known and accepted limitation, documented above.

Test instructions for QA when the code is in the RC

  • QA should use the same steps as above.

QA can test this PR by following these steps:

  • Same as the acceptance test steps above.

Impact check

This PR affects the following parts of the plugin, which may require extra testing:

  • Rewrite rules. A new rule is registered at the top of the list on front-end requests when the feature is on. It is worth confirming that ordinary permalinks, XML sitemaps and any other custom routes still resolve correctly.
  • The schema aggregator REST endpoint. /wp-json/yoast/v1/schema-aggregator/get-xml was refactored to use the new shared provider. Its output, status and headers should be unchanged.
  • The schema map transient cache. The REST route and the new root path now share yoast_schema_aggregator_xml_sitemap_v1, so cache invalidation on saving content affects both.

Other environments

  • This PR also affects Shopify. I have added a changelog entry starting with [shopify-seo], added test instructions for Shopify and attached the Shopify label to this PR.
  • This PR also affects Yoast SEO for Google Docs. I have added a changelog entry starting with [yoast-doc-extension], added test instructions for Yoast SEO for Google Docs and attached the Google Docs Add-on label to this PR.

Documentation

  • I have written documentation for this change. For example, comments in the Relevant technical choices, comments in the code, documentation on Confluence / shared Google Drive / Yoast developer portal, or other.

Quality assurance

  • I have tested this code to the best of my abilities.
  • During testing, I had activated all plugins that Yoast SEO provides integrations for.
  • I have added unit tests to verify the code works as intended.
  • If any part of the code is behind a feature flag, my test instructions also cover cases where the feature flag is switched off.
  • I have written this PR in accordance with my team's definition of done.
  • I have checked that the base branch is correctly set.
  • I have run grunt build:images and committed the results, if my PR introduces or edits images or SVGs.

Innovation

  • No innovation project is applicable for this PR.
  • This PR falls under an innovation project. I have attached the innovation label.
  • I have added my hours to the WBSO document.

Fixes Yoast/reserved-tasks#1161

thijsoo and others added 3 commits July 27, 2026 14:02
The XML REST route was the only place performing the read-through-cache
sequence for the schema map. A second consumer is about to need it, so the
sequence moves into Schema_Map_Xml_Provider rather than being duplicated.

This also gives the sequence its own unit tests; it previously had none,
since the route only exercised it through the WP integration test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The schema visualiser used to discover a site's schema map through the
Schemamap directive in robots.txt. That directive has been removed, and the
visualiser now probes the well-known root /schemamap.xml first, falling back
to the wp-json endpoint. Only the fallback was served until now.

The route mirrors WP_Sitemaps: a rewrite rule pointing at a query var, and a
template_redirect handler that echoes the XML and exits. Yoast_Dynamic_Rewrites
is used instead of add_rewrite_rule() so the rule follows the feature toggle
without needing a rewrite flush. Integrations register on init priority 10,
after yoast_add_dynamic_rewrite_rules has fired, so the rule is added directly
the way WPSEO_Sitemaps::register_sitemap() does.

Under plain permalinks the rewrite does not run, so /schemamap.xml 404s there,
the same as /sitemap_index.xml. Discovery falls back to the wp-json endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses code review on the /schemamap.xml route.

Build the XML before committing any header. get_xml() queries the database and
Schema_Map_Xml_Renderer can throw, so a failure after the 200 and the cache
headers went out would let a proxy store an HTML error page at the well-known
path for the full max-age. The visualiser rejects anything that is not a
sitemap document, so that window mattered.

Render on pre_get_posts priority 1 with an is_main_query() guard instead of
template_redirect priority 0, matching WPSEO_Sitemaps::redirect(). This skips
the main posts query, which the response never uses, and leaves fewer
third-party callbacks able to echo ahead of the document.

Clear Expires alongside Cache-Control. wp_get_nocache_headers() sets both for
logged-in users, so overriding only Cache-Control left the two contradicting.
Headers now go through Redirect_Helper, which exists to make them testable;
header() and headers_sent() are PHP internals that Patchwork cannot stub.

Drop the redirect_canonical filter. Core registers redirect_canonical() on
template_redirect priority 10 and applies the filter nowhere else, so exiting
during pre_get_posts makes the callback unreachable. Its two tests asserted a
path no request can take.

Also adds real coverage for send_headers() and the build-before-headers
ordering, asserts the hook priority rather than just its registration, and
replaces a WP cache test that passed whether or not caching existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thijsoo thijsoo added the changelog: enhancement Needs to be included in the 'Enhancements' category in the changelog label Jul 27, 2026
@coveralls-official

coveralls-official Bot commented Jul 27, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 2

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage decreased (-0.5%) to 54.947%

Details

  • Coverage decreased (-0.5%) from the base build.
  • Patch coverage: 5 uncovered changes across 2 files (39 of 44 lines covered, 88.64%).
  • 419 coverage regressions across 8 files.

Uncovered Changes

File Changed Covered %
src/schema-aggregator/user-interface/schemamap-xml-rewrite-integration.php 29 26 89.66%
src/schema-aggregator/user-interface/site-schema-aggregator-xml-route.php 3 1 33.33%
Total (3 files) 44 39 88.64%

Coverage Regressions

419 previously-covered lines in 8 files lost coverage.

File Lines Losing Coverage Coverage
src/abilities/user-interface/abilities-integration.php 199 8.31%
inc/sitemaps/class-sitemap-image-parser.php 102 9.66%
inc/sitemaps/class-post-type-sitemap-provider.php 52 37.11%
src/repositories/indexable-repository.php 51 41.79%
src/routes/indexing-route.php 6 88.61%
src/bulk-editor/user-interface/bulk-editor-integration.php 5 92.42%
src/general/user-interface/general-page-integration.php 3 96.39%
src/commands/index-command.php 1 98.91%

Coverage Stats

Coverage Status
Relevant Lines: 70432
Covered Lines: 38637
Line Coverage: 54.86%
Relevant Branches: 17324
Covered Branches: 9582
Branch Coverage: 55.31%
Branches in Coverage %: Yes
Coverage Strength: 42729.8 hits per line

💛 - Coveralls

headers_already_sent() and finish_request() are protected one-line wrappers,
which read as unexplained indirection. Both exist so Maybe_Render_Schema_Map_Test
can stub them on a Mockery partial mock: exit() would terminate the test runner,
and the test process cannot control what headers_sent() reports. Record that on
the methods, along with why they are protected rather than private.

The @codeCoverageIgnore reason on headers_already_sent() was carrying the whole
explanation, so it is trimmed back to just the coverage justification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thijsoo
thijsoo marked this pull request as ready for review July 28, 2026 12:49
@leonidasmi
leonidasmi requested a review from Copilot July 31, 2026 06:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes the aggregated schema map discoverable at the well-known root URL /schemamap.xml by introducing a rewrite-based front-end integration, and refactors the existing REST route to reuse a shared XML provider (so both endpoints serve identical content and share caching).

Changes:

  • Add a front-end rewrite integration that serves the schema map at /schemamap.xml (feature-flagged via the existing schema aggregation endpoint toggle).
  • Introduce a Schema_Map_Xml_Provider to centralize XML generation + transient caching, and update the REST route to use it.
  • Add/adjust WP + unit tests to cover routing behavior, header behavior, and provider caching behavior.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/WP/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_Xml_Route_Test.php Updates WP integration test coverage for the REST XML route and shared transient usage.
tests/WP/Schema_Aggregator/User_Interface/Schemamap_Xml_Rewrite_Integration_Test.php Adds WP integration tests asserting rewrite rule registration and query-var routing for /schemamap.xml.
tests/Unit/Schema_Aggregator/User_Interface/Site_Schema_Aggregator_XML_Route/Abstract_Site_Schema_Aggregator_Xml_Route_Test.php Refactors unit test base to mock the new XML provider dependency.
tests/Unit/Schema_Aggregator/User_Interface/Schemamap_Xml_Rewrite_Integration/Abstract_Schemamap_Xml_Rewrite_Integration_Test.php Adds a unit-test base class for the new rewrite integration.
tests/Unit/Schema_Aggregator/User_Interface/Schemamap_Xml_Rewrite_Integration/Add_Query_Vars_Test.php Unit tests for adding the yoast_schemamap query var.
tests/Unit/Schema_Aggregator/User_Interface/Schemamap_Xml_Rewrite_Integration/Add_Rewrite_Rules_Test.php Unit tests for the dynamic rewrite rule registration.
tests/Unit/Schema_Aggregator/User_Interface/Schemamap_Xml_Rewrite_Integration/Get_Conditionals_Test.php Unit tests for conditionals (front-end + schema-aggregator conditional).
tests/Unit/Schema_Aggregator/User_Interface/Schemamap_Xml_Rewrite_Integration/Maybe_Render_Schema_Map_Test.php Unit tests for conditional rendering logic, ordering, and output behavior.
tests/Unit/Schema_Aggregator/User_Interface/Schemamap_Xml_Rewrite_Integration/Register_Hooks_Test.php Unit tests for hook registration and priority expectations.
tests/Unit/Schema_Aggregator/User_Interface/Schemamap_Xml_Rewrite_Integration/Send_Headers_Test.php Unit tests asserting XML response headers are set and Expires is removed.
tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Provider/Abstract_Schema_Map_Xml_Provider_Test.php Adds provider unit-test base with mocks for command handler/cache/config.
tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Provider/Constructor_Test.php Unit tests for provider constructor wiring.
tests/Unit/Schema_Aggregator/Application/Schema_Map/Schema_Map_Xml_Provider/Get_Xml_Test.php Unit tests for cached vs cache-miss behavior of provider XML generation.
src/schema-aggregator/user-interface/site-schema-aggregator-xml-route.php Refactors REST XML route to use the shared Schema_Map_Xml_Provider.
src/schema-aggregator/user-interface/schemamap-xml-rewrite-integration.php Adds the new front-end integration serving /schemamap.xml via rewrite/query var and early render hook.
src/schema-aggregator/application/schema_map/schema-map-xml-provider.php Adds a shared provider responsible for generating and caching the schema map XML.

Comment on lines 82 to 86
/**
* Returns a XML representation of the possible post types that can be used for schema.
*
* @return WP_REST_Response|WP_Error The success or failure response.
* @return WP_REST_Response The response.
*/

Copy link
Copy Markdown
Contributor

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

Comment on lines 24 to 28
public function set_up() {
$this->aggregate_site_schema_map_command_handler = Mockery::mock( Aggregate_Site_Schema_Map_Command_Handler::class );
$this->xml_cache_manager = Mockery::mock( Xml_Manager::class );
$this->aggregator_config = Mockery::mock( Aggregator_Config::class );
$this->instance = new Site_Schema_Aggregator_Xml_Route( $this->aggregate_site_schema_map_command_handler, $this->xml_cache_manager, $this->aggregator_config );
\YoastSEO()->helpers->options->set( 'enable_schema_aggregation_endpoint', true );

\do_action( 'rest_api_init' );
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legit feedback, do you agree @thijsoo ?

@leonidasmi leonidasmi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CR + Acceptance is ✅

There are two minor suggestions from Copilot that I think are legit @thijsoo. Feel free to merge with or without them, no need for me to re-test

@thijsoo thijsoo added this to the 28.3 milestone Jul 31, 2026
@thijsoo
thijsoo merged commit dc332a0 into trunk Jul 31, 2026
29 checks passed
@thijsoo
thijsoo deleted the 1161-schema-visualiser-update-discovery-mechanism branch July 31, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog: enhancement Needs to be included in the 'Enhancements' category in the changelog

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants