Skip to content

Seo/sitemap filter orphan entries - #12

Merged
pedrobernardina merged 9 commits into
main-altfrom
seo/sitemap-filter-orphan-entries
Sep 2, 2026
Merged

pedrobernardina merged 9 commits into
main-altfrom
seo/sitemap-filter-orphan-entries

Conversation

@pedrobernardina

Copy link
Copy Markdown

What is this Contribution About?

Please provide a brief description of the changes or enhancements you are proposing in this pull request.

Issue Link

Please link to the relevant issue that this pull request addresses:

Loom Video

Record a quick screencast describing your changes to help the team understand and review your contribution. This will greatly assist in the review process.

Demonstration Link

Provide a link to a branch or environment where this pull request can be tested and seen in action.

Pedro Bernardina added 9 commits August 31, 2026 14:45
The sitemap is proxied from VTEX, so it lists whatever the platform's
category tree holds. Categories are created upstream by a different team
than the one that builds the storefront pages, so an entry can point at a
path no page serves — which answers 404. Announcing such a URL to crawlers
is worse than omitting it.

Behind `removeEntriesWithoutPage`, every <url> is now matched against the
paths of the pages that actually exist, read with the same blockSelector
website/loaders/pages.ts uses. Dropped entries are logged so the missing
page can be created; the entry returns on its own once it is. The catch-all
page is deliberately left out of the matchers: it matches every path, and
counting it would make the filter inert.

Also matches the storefront host instead of the exact configured URL when
rewriting <loc>, so a variation the platform emits — http, or no trailing
slash — cannot leak the platform host into the index.
Three defects from review, none of which the earlier commit could have
shown working:

The flag never reached the handler. The proxy loader builds the sitemap
routes with a fixed set of props, and removeEntriesWithoutPage was not
among them, so the handler always saw undefined and the filter never ran.
It is now threaded through the loader to both sitemap routes.

The matcher only imitated the router. Hand-rolled as a regex, it diverged
on case, on trailing slashes and on percent-encoding: a page at /maçã does
answer /ma%C3%A7%C3%A3 but would have been dropped, while /foo/ and /FOO,
which answer nothing, would have been kept. It now builds the very same
URLPattern the router builds, which also fixes a false positive found
against production data — a page whose path carries a query string was
being dropped, because the regex escaped the template whole.

An empty page list disabled the filter, conflating a valid read with a
failed one, which the catch already covers. The filter now always runs;
what is refused instead is the narrower and unrecoverable case of dropping
every entry, since an empty sitemap withdraws the whole site from the
index and nothing downstream would notice.
Three defects from review.

The rewritten body was returned under the upstream's framing headers. It
is no longer the gzip stream content-encoding claims, nor the length
content-length states — a mismatch that predates the filter, since
rewriting the host already shortens every <loc>. Both headers are dropped
from a clone of the response's.

The guard against emptying a sitemap watched the symptom instead of the
cause. Each child sitemap is handled on its own, so one made entirely of
orphan entries is a legitimate emptying, and refusing it left those URLs
published. What is unrecoverable is having no page to match against at
all — that would empty every sitemap — so the guard now sits there.

A <loc> holds XML and arrives entity-encoded, so a URL written as
"?a=1&amp;b=2" was compared literally against a route the router sees as
"?a=1&b=2", dropping pages whose URL carries more than one parameter.
Entities are decoded, numeric references included, before matching.
The sitemap is no longer rewritten. Removing entries meant reserializing a
body the upstream's headers no longer described, and deciding on the
storefront's behalf which URLs a crawler may see; a warning gives whoever
can create the missing page the same information without either. The
response goes back out exactly as it came in, and the prop says so.

The check itself is now taken from the routes actually being served, read
from the request state the way website/handlers/sitemap.ts does, rather
than from the raw page blocks: a page hidden by hidePagesInDeco answers
only with ?rdc=true, and reading its block would have called it reachable.

Catch-all routes are recognized by probing what they answer instead of by
their spelling, since the router normalizes "/*", "/(.*)" and an absolute
"https://store.test/*" to the same pattern and any of them would have made
the check vacuous.

Routes with no pattern syntax — nearly all of them — are indexed in a set,
so an orphan entry no longer walks every route: 10k orphan URLs against
500 routes went from seconds to 92ms, and the protocol allows 50k URLs per
file. The log carries a count and a bounded sample instead of every URL,
which on a large sitemap was megabytes to the console and the logger both.

Finally, the host rewrite now requires the host to end where the match
does, with an optional port. Unanchored, "shop.example.com" rewrote
through the prefix of "shop.example.com.br" and mangled ":443" URLs.
Restores the removal. A URL the platform lists but no page serves answers
404, and announcing it to crawlers is worse than omitting it; it returns
on its own once the page exists, since nothing here is persisted.

Two defects from review, both of which removal turns from noise into
damage. A catch-all was recognized by probing an improbable path, which
"/:department/:category" also answers — every two-segment route would have
been dropped from the index and every two-segment page erased from the
sitemap. The router normalizes each spelling of a catch-all to the
pathname "/*", so that is what is compared now.

And the scan read every <loc> in the document, including the <sitemap>
entries of an index, which name documents rather than pages: an external
sitemap added through `include` would have been reported, and now removed.
Matching whole <url> blocks confines the check to the urlset, which the
removal needed anyway.
The response is decoded and rewritten before being served: the host is
swapped in every <loc>, entries are dropped, includes are added. What the
upstream sent about its own bytes stops holding — content-length is the
length of a different document, content-encoding announces a gzip stream
that was already decoded, and etag names the platform's version rather
than the one being served, so a client could revalidate its way into a
stale body.

This predates the entry removal: rewriting the host alone already shortens
every <loc>. Deleting the three from a clone lets the server frame the
response it is actually sending. VTEX currently sends no etag for the
sitemap, so that one is a precaution.
"/:path(.*)" and "/:path*" are catch-alls that normalize to themselves,
not to "/*", so comparing the pathname let them into the index — and a
route matching every URL makes the whole check vacuous. Probing a single
path does not work either: "/:department/:category" answers two segments
of anything and would be mistaken for one.

What separates a catch-all from every other route is that it answers paths
of differing depth, so that is what is asked of it. This recognizes "/*",
"*", "/(.*)", "/:path(.*)", "/:path*" and the absolute form, while
"/loja/:path*" — a catch-all under a prefix, which does not match every
URL — stays an ordinary route.
Forwards only the headers still true of a document this handler rewrote —
content-type, cache-control and vary — instead of deleting the ones known
to be wrong one at a time. The upstream's content-length counts bytes of
another document, content-encoding announces a gzip stream reading the
body already undid, etag and last-modified name the platform's version
rather than the served one, and accept-ranges offers ranges over offsets
into a document nobody will receive. The list also stops the next such
header from having to be found in production first.

This drops the platform's x-vtex-* cache diagnostics from the response,
which described the upstream fetch rather than what is served.

A 304 is also returned untouched now. It carries no body to rewrite, and
handing one to a Response throws outright: "Response with null body status
cannot have body". 204 and 205 are covered by the same rule.
The previous guard named the statuses that carry no body, which left the
ones carrying the wrong body. A 206 was rewritten as though its fragment
were a whole sitemap, and a 301 or 302 came out without its Location,
since a forwarded header list cannot carry what it does not name — a
redirect the earlier code, copying every header, had passed through
intact. An error body was being parsed as XML too.

Only a complete document can be rewritten as one, so only a 200 is.
@pedrobernardina
pedrobernardina merged commit 2202c3d into main-alt Sep 2, 2026
@pedrobernardina
pedrobernardina deleted the seo/sitemap-filter-orphan-entries branch September 2, 2026 11:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant