Skip to content

fix(seo): publish the URLs the host actually serves - #172

Open
Batuis wants to merge 3 commits into
mainfrom
fix/trailing-slash-urls
Open

fix(seo): publish the URLs the host actually serves#172
Batuis wants to merge 3 commits into
mainfrom
fix/trailing-slash-urls

Conversation

@Batuis

@Batuis Batuis commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #171.

The problem

Pages are directories on disk, and GitHub Pages answers a directory request with a 301 to the slashed form. That cannot be turned off. Every URL the site published named the unslashed form, so all eighteen were addresses it redirects rather than addresses it serves:

sitemap says      https://stabileo.com/en/blog
the host answers  301 → https://stabileo.com/en/blog/
and that page     <link rel="canonical" href=".../en/blog">

A canonical pointing at a URL that redirects back to the page declaring it, and an hreflang set built the same way.

Why it matters, stated honestly

This is not a ranking lever. Google resolves the chain in practice, and nobody moves up a position because of it.

It is worth doing for two narrower reasons:

  1. The language linkage. Google is explicit that the canonical and the hreflang have to agree, and that if they do not it may ignore the hreflang — which is the three-language connection the whole prefix scheme was built to express.
  2. Timing. The site went live days ago and is still being crawled. Changing the canonical form after the URLs are indexed costs a reindex and a period of ambiguity. Doing it now costs nothing.

The Search Console reports we are about to rely on will also be readable: sitemap URLs will appear as indexed pages rather than as "page with redirect".

What changed

publicHref ends every path in a slash; the sitemap, the canonical, the hreflang set and every internal link follow from it. parsePublicPath already read both forms, so links people already hold keep working — they take the one redirect they always did.

The root language handoff now sends /es/ instead of /es, which saves a Spanish or Portuguese visitor a second redirect that was ours to avoid.

The gate

A test that asserts the artifact rather than the intention. For every <loc> in the sitemap it requires a real file at that exact path, that the page declares itself canonical at the address it is served from, and that every hreflang target is itself served rather than redirected.

Checked on disk, not over HTTP, on purpose: vite preview serves both forms happily, so an HTTP test would have passed on the very defect that reached production.

The first draft used existsSync, which answers true for a directory — precisely what gets redirected — so it passed when the fix was reverted. Caught by running that control; it now requires a file.

Considered and rejected

Keeping the unslashed URLs by emitting flat files (en/blog.html rather than en/blog/index.html). It works for posts — GitHub Pages serves /404 from 404.html with no redirect, verified — but not for /en, where an en.html file and an en/ directory would have to coexist and the winner is up to the host. Too fragile to save a slash.

Gates

test:unit 7,132 · typecheck at baseline 479 · check:gate · build with prerender · @smoke + @landing.

Batuis added 3 commits August 24, 2026 23:08
Pages are directories on disk, and GitHub Pages answers a directory request
with a 301 to the slashed form. It cannot be turned off. Every URL the site
published named the UNSLASHED form, so all eighteen were addresses it
redirects rather than addresses it serves:

  sitemap        https://stabileo.com/en/blog
  host           301 -> https://stabileo.com/en/blog/
  and that page  <link rel="canonical" href=".../en/blog">

A canonical pointing at a URL that redirects back to the page declaring it,
and an hreflang set built the same way. Google resolves the chain in
practice; what it says about the language links is sharper — the canonical
and the hreflang have to agree or it may ignore the hreflang, which is the
three-language linkage the whole prefix scheme exists to express.

`publicHref` now ends every path in a slash, and the sitemap, the canonical,
the hreflang set and every internal link follow from it. `parsePublicPath`
already read both forms, so links people already hold keep working; they take
the one redirect they always did. The root language handoff sends '/es/'
rather than '/es', which saves a Spanish or Portuguese visitor a second
redirect that was ours to avoid.

Also here: a gate that asserts the artifact, not the intention. For every
<loc> in the sitemap it requires a real file at that exact path, that the
page declares itself canonical at the address it is served from, and that
every hreflang target is itself served rather than redirected.

It is checked on disk rather than over HTTP on purpose: `vite preview`
serves both forms happily, so an HTTP test would have passed on the very
defect that reached production. The first draft used `existsSync`, which
answers true for a directory — precisely what gets redirected — and passed
when the fix was reverted. Caught by running that control; it now requires a
file.
Twenty-seven assertions encoded the unslashed form, anchored with $, so they
failed the moment the URLs became the ones the host serves. Updated rather
than loosened: the anchor is what gives them value, so they gained a slash
and kept the anchor.

Three needed a decision rather than a slash:

  - The cold deep link carries no language prefix — the shape links handed
    out before the prefixes existed — and App.svelte restores those verbatim
    rather than rewriting somebody's saved address. That case now accepts
    either form, and a new one covers the PREFIXED handoff, which IS
    normalised through publicHref so the bar and the page's canonical agree.
  - The back-button case booted on '/pt/blog/x'. In production the host 301s
    that before the app loads, so it is a state the site cannot be in; vite
    preview serves both, which is what let the test sit in it. It boots on
    the slashed address now.
  - The prerender helper built its expected canonical without the slash.

Also in App.svelte: a route restored from 404.html's ?route= now goes through
publicHref when it carries a locale, instead of being written back exactly as
it arrived.
CI failed this branch with `browser.newContext: Test ended` on a test that
never started. Not an assertion — the browser had stopped being able to open
contexts.

The suite runs 327 cases through ONE browser (`workers: 1`,
`fullyParallel: false`, SwiftShader software GL), chosen so WebGL timing is
deterministic. It wedges near the end, and every case added brings the wedge
forward: the same failure took two unrelated specs down on the parent branch
until a seven-context test was removed from it, and this branch tipped it
again with one.

So the prefixed `?route=` assertion moves into the cold-deep-link test it
belongs with. Same coverage, two navigations in one context instead of two
contexts.

That is a workaround. The suite is at the edge of what one browser survives
and the next test to be written will hit this again; sharding the e2e job is
the actual fix and is not this PR.
@diegokingston
diegokingston changed the base branch from fix/readme-and-favicon to main August 26, 2026 15:38
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