# Stream B: crawlable pages, SEO corrections, then the hub pages
> **Status 10 Sep:** B1 to B5 and B7 landed (handoffs 5 Sep); B6 (category and category-at-airport pages) waits on `decide-page-types-18-sep`. The structure review's outcome landed through `/stream-s` on 9 and 10 Sep (handoff 2026-09-10). The body below is the brief as asked on 4 Sep.
**Rewritten 2026-09-04 (Fri) against build plan v3.** Supersedes the 3 Sep brief. Runs Fri 4
night → Fri 11, then Wave 3.

## Goal
Content that search engines and AI assistants read without running the application, the
machine-readable layer corrected, and the page types that give them something worth ranking.
The structure proposal is done (`/structure`, hidden). **Hub pages start Fri 11 either way**;
what gates them is the brands table (Stream A, migration #3), not Mark's silence.

## Tonight (Fri 4 → Sat 6), in this order; task ids are on /plan
1. **B1 Product page body rendered server-side.** Today the served body is `<div id="root">`
   with 0 bytes of visible text. Emit, in the served HTML for `/products/<slug>-<id>`, exactly
   what the SPA renders: the H1, brand and size, the price table (airport, price, currency,
   observed date), awards, the links to brand/category/airports. **Server markup must match
   what users see** (anything else is cloaking). Keep the head injection, the LCP preload and
   the zero-layout-shift work intact; measure before/after with the existing PageSpeed notes in
   the handoff. The SPA hydrates over it or replaces it; either way the no-JS fetch must show
   the content. Test: a `curl` of a product page contains the product name and a price.
2. **B2 the §6 corrections**, all in `seo.py`/`main.py`: `PUBLIC_BASE_URL` in config (fall
   back to the Host header only in dev); absolute `url`, `offers.url`, breadcrumb `item`, and
   `@id`; **gtin13/gtin12/gtin8/gtin14 by width** instead of the generic property; availability
   **omitted** where `in_stock` is null (29% of observations), never defaulted to InStock;
   per-airport `Offer` nested with `priceCurrency`; `Organization` (name, logo, url, sameAs
   later); **drop `SearchAction`** (retired Nov 2024), keep `WebSite`; **real 404s** for unknown
   product ids and unknown routes (today they return 200 with the default head); HTTP `HEAD`
   support (405 today); `ETag`/`Last-Modified` on HTML, sitemap and robots; **`Cache-Control:
   no-store` on HTML and JSON** so a production CDN cannot cache admin changes. Tests for
   `product_head`, `sitemap_xml`, the 404 path and the gtin widths.
3. **B3 the structure page sweep** (`lib/structure.ts`, `components/SiteMap.tsx`) before it goes
   to Mark: scale framed as "the sixteen collected today; nineteen at launch with Paris, Seoul
   and Singapore"; the SiteMap caption to four airports with a **linked live number**, not a
   typed one; the "WebSite and SearchAction already ship" line aligned with B2; the four
   "free / almost nothing / nearly free" phrases replaced ("a rules change on our side", "low
   cost to emit", "a declaration rather than a rebuild"); beauty counts re-measured against the
   database; **leave the "which page types ship 18 Sep" sentence as a marked placeholder** for
   rian (§10 #10). House style: no em dashes; never "cheap", "free", "the code is yours".

## If time remains tonight
- `resolve_product_id()` consumer: make `main.py`'s product route and `routers/catalog.py`
  301 through a helper that A will back with `merged_into_id` in migration #3 (stub returns the
  id unchanged until then).
- A no-network test that walks `app.routes` and lists every mutating route (Stream R's future
  gate will consume it).

## Wave 2 (Mon 7 – Fri 11), in this order; task ids are on /plan
**B4** airport pages → **B7** IndexNow, per-bot robots, RSS, llms.txt, Dataset page → **B5** brand pages (after A8/A9) → **B6** category pages (after rian's §10 #10).

Airport pages and brand pages on the agreed structure (brand slugs **from the brands table**,
never from raw strings); ItemList + CollectionPage + Airport markup; sitemap entries for hubs;
internal links and breadcrumbs everywhere; IndexNow ping from the collector's changed-URL list.
Category and category-at-airport pages per rian's answer to §10 #10. Every merged product 301s.

## Owns
`app/services/seo.py`, routes in `app/main.py`, `app/routers/catalog.py`, `app/config.py` for
`PUBLIC_BASE_URL`, new `web/src/pages/AirportPage.tsx` / `BrandPage.tsx` / `CategoryPage.tsx`,
`web/src/lib/urls.ts`, `web/src/lib/structure.ts`, `web/src/components/SiteMap.tsx`, their
tests, `main/docs/SEO.md` (create it: "Sources of truth: app/services/seo.py, app/main.py").

## Must not touch
`HomePage.tsx`, `styles/*` (Stream C), `PlanPage.tsx` and `routers/plan.py` (rian's), any
collector or `ingest.py`/`normalize.py`/`taxonomy.py`/`models/` (Stream A), `check.sh` (Docs).

## Rules
- Anything the client reads: no em dashes; never "cheap", "free", or "the code is yours".
- Performance is hard-won: keep CLS near zero, no render-blocking resources, re-check after B1.
- You never deploy. Build and test locally; `npm run build` must pass; tell rian you are green.
- No migration in this stream.
