Problem
A trail page serves no heading and no metadata of its own. Everything that identifies which trail it is arrives only after hydration, so a crawler, a shared link preview, and a screen reader before the runtime boots all get a page about nothing in particular.
Fetched from the QA app at /trail/9:
| served | |
|---|---|
<title> | Trail Details - Wildloop |
og:title | Wildloop — Trail discovery and GPS tracking for runners and hikers |
meta[name=description] | the site-wide default |
<h1> | {{ trail.name }}, under display: none |
Every trail page serves those same four values, so all of them are indistinguishable from one another and from the site root.
/trails has the heading half of the same problem — resources/views/trails.stx:1120
is <h1 ...>{{ listHeading() }}</h1>, served as that literal string — though its
<title> and description are specific and correct.
The home page is fine, and shows what the difference is:
resources/views/index.stx:462 is <h1 class="hero-title">Find your next adventure</h1>
— literal text, rendered server-side.
Why it matters
Half a million trail pages are the catalogue. They are the reason to index the site at all, and right now each one tells a crawler the same generic thing. A shared link to a specific trail previews as the site's front page.
The heading has a second cost. <h1> is how a screen reader user establishes what
a page is about, and until the runtime boots there is none — on a page whose
entire purpose is one named trail. The markup is worse than absent: it contains
the literal characters {{ trail.name }}.
This is also the half the .pw.ts specs cannot see. They drive a real browser, so
the runtime has already filled all of it in by the time they assert. The gap is
only visible in what the server sent, which is what
tests/browser/served-markup.test.ts now reads.
Why the two pages fail differently
They need different fixes.
/trail/{id} — resources/views/trail/[id].stx:840. The <h1> sits inside
<div :if="trail">, and trail is falsy server-side, so the whole header block is
hidden rather than rendered. The metadata is generic for the same underlying
reason: nothing resolves the trail before the response is written.
/trails — resources/views/trails.stx:1120. Not gated by anything; the heading
is simply a signal expression, and {{ listHeading() }} is not evaluated
server-side. Its title and description are already correct, so only the heading
needs doing.
Proposed scope
- Resolve the trail server-side for
/trail/{id}and render its name into the<h1>, the<title>,og:title,og:descriptionandmeta[name=description]. The id is in the path, so this is a question of where the data is bound rather than of fetching something unavailable. - Give
/trailsa server-rendered heading. Its three list modes already drivelistHeading(); the default — the catalogue — is the one a crawler sees and is static. - A trail that does not exist should render its not-found state server-side too, rather than an empty header, so a 404 is a 404 to a crawler.
- Worth checking the other
:if-gated headers at the same time:feed.stx,clubs.stx,records.stx,challenges.stxandsettings.stxall contain an<h1>, and the ones behind a client condition will have this shape.
Leave the interactive parts client-rendered. Nothing here asks for the filters, the map or the review sheet to be server-rendered — only the identity of the page.
Acceptance
/trail/{id}serves an<h1>containing the trail's name, with no{{anywhere in the rendered text.- Its
<title>,og:titleanddescriptionname the trail, and two different trails serve different values for each. /trailsserves a rendered<h1>.- A nonexistent trail id serves its not-found state in the markup.
- The two cases currently skipped in
tests/browser/served-markup.test.tsare unskipped, and the metadata assertion is strengthened: asserting the trail title merely differs from the home page's passes againstTrail Details - Wildloop, which is how this went unnoticed.
Found by tests/browser/served-markup.test.ts, on its first run.